Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions scripts/test-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import * as fs from 'fs';
import * as path from 'path';
import sanitizeHtml from 'sanitize-html';
import { formatHintOutput } from '../src/lib/formatHintOutput';

// --- ANSI colors ---
const RED = '\x1b[0;31m';
Expand Down Expand Up @@ -71,6 +71,7 @@ interface ContractCase {
payload: Record<string, unknown>;
expectedStatus: number;
apiKey?: string | null;
requiresAuthEnforcement?: boolean;
}

// --- Helpers ---
Expand Down Expand Up @@ -108,12 +109,14 @@ const contractCases: ContractCase[] = [
payload: validContractRequest,
expectedStatus: 401,
apiKey: null,
requiresAuthEnforcement: true,
},
{
name: 'invalid API key is rejected before processing',
payload: validContractRequest,
expectedStatus: 403,
apiKey: `${API_KEY}-invalid`,
requiresAuthEnforcement: true,
},
{
name: 'unknown request fields are rejected',
Expand Down Expand Up @@ -179,11 +182,38 @@ async function checkHealth(): Promise<void> {
}
}

async function detectAuthEnforcement(): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/hint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ probe: 'auth-enforcement' }),
});
return response.status === 401;
} catch {
return false;
}
}

async function runContractTests(): Promise<boolean> {
console.log(`${YELLOW}Running local API contract checks...${NC}`);
let failed = 0;
let skipped = 0;

const authEnforced = await detectAuthEnforcement();
if (!authEnforced) {
console.log(
` ${YELLOW}!${NC} API key auth is not enforced on this server; auth cases will be skipped`,
);
}

for (const testCase of contractCases) {
if (testCase.requiresAuthEnforcement && !authEnforced) {
skipped++;
console.log(` ${YELLOW}-${NC} ${testCase.name} (skipped)`);
continue;
}

const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const apiKey = testCase.apiKey === undefined ? API_KEY : testCase.apiKey;
if (apiKey !== null) headers['X-API-Key'] = apiKey;
Expand All @@ -210,18 +240,18 @@ async function runContractTests(): Promise<boolean> {
}
}

if (skipped > 0) {
console.log(
` ${YELLOW}${skipped} auth case(s) skipped${NC} — run against staging to exercise them`,
);
}

console.log('');
return failed === 0;
}

function followsHintOutputContract(hint: string): boolean {
return (
sanitizeHtml(hint, {
allowedTags: ['code'],
allowedAttributes: {},
disallowedTagsMode: 'escape',
}) === hint
);
return formatHintOutput(hint) === hint;
}

// --- Run a single test ---
Expand Down
4 changes: 3 additions & 1 deletion src/config/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SERVER_URL } from './env';
import { MAX_HINT_RESPONSE_CHARS } from '../lib/formatHintOutput';

const swaggerDefinition: Record<string, unknown> = {
openapi: '3.0.0',
Expand Down Expand Up @@ -160,8 +161,9 @@ export const sharedSchemas = [
properties: {
hint: {
type: 'string',
maxLength: MAX_HINT_RESPONSE_CHARS,
description:
'The AI-generated hint. Only <code> elements without attributes are active HTML; all other tags are encoded as text.',
'The AI-generated hint. Only <code> elements without attributes are active HTML; all other tags are encoded as text. Safe for element-context insertion only: double and single quotes are not escaped, so do not interpolate this value into an HTML attribute.',
example: 'Check whether your <code>sum</code> function returns a value.',
},
model_used: {
Expand Down
46 changes: 45 additions & 1 deletion src/lib/__tests__/formatHintOutput.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { formatHintOutput, MAX_HINT_CODE_POINTS } from '../formatHintOutput';
import {
formatHintOutput,
MAX_HINT_CODE_POINTS,
MAX_HINT_RESPONSE_CHARS,
} from '../formatHintOutput';

describe('formatHintOutput', () => {
it('preserves attribute-free code elements', () => {
Expand Down Expand Up @@ -59,4 +63,44 @@ describe('formatHintOutput', () => {
it('returns an empty string for empty input', () => {
expect(formatHintOutput(' ')).toBe('');
});

it('keeps attributes readable as text on escaped elements', () => {
expect(formatHintOutput('Your <img src="cat.jpg"> is missing alt text.')).toBe(
'Your &lt;img src="cat.jpg"&gt; is missing alt text.',
);
});

it('keeps attributes readable as text inside code elements', () => {
expect(formatHintOutput('Use <code><meta charset="utf-8"></code>.')).toBe(
'Use <code>&lt;meta charset="utf-8"&gt;</code>.',
);
});

it('does not let raw-text elements swallow the closing code tag', () => {
expect(formatHintOutput('Wrap it in <code><textarea></code> and try again.')).toBe(
'Wrap it in <code>&lt;textarea&gt;</code> and try again.',
);
});

it('escapes HTML comments instead of deleting them', () => {
expect(formatHintOutput('Comments look like <!-- this -->, and try again.')).toBe(
'Comments look like &lt;!-- this --&gt;, and try again.',
);
});

it('escapes doctype declarations instead of deleting them', () => {
expect(formatHintOutput('Start with <!doctype html> at the top.')).toBe(
'Start with &lt;!doctype html&gt; at the top.',
);
});

it('escapes a bare ampersand', () => {
expect(formatHintOutput('Tom & Jerry')).toBe('Tom &amp; Jerry');
});

it('stays within the documented response ceiling for maximal escaping', () => {
const worstCase = '&'.repeat(MAX_HINT_CODE_POINTS * 2);

expect(formatHintOutput(worstCase).length).toBeLessThanOrEqual(MAX_HINT_RESPONSE_CHARS);
});
});
24 changes: 24 additions & 0 deletions src/lib/__tests__/normalizeHintRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ describe('normalizeHintRequest', () => {
expect(result.hints).toBe('failureinjected');
});

it('removes prompt-frame tags nested inside themselves', () => {
const result = normalizeHintRequest(
validBody({
description: '<challenge_<challenge_description>description>',
userInput: '<stu<student_code>dent_code>',
seed: '</stu</student_code>dent_code>',
hints: [{ text: '<fail<failing_test>ing_test>', failed: true }],
}),
);

expect(result.description).toBe('');
expect(result.userInput).toBe('');
expect(result.seed).toBe('');
expect(result.hints).toBe('');
});

it('preserves ordinary HTML in learner code', () => {
const result = normalizeHintRequest(
validBody({ userInput: '<main><output id="result"></output></main>' }),
Expand All @@ -79,6 +95,14 @@ describe('normalizeHintRequest', () => {
expect(result.userInput).toBe('<main><output id="result"></output></main>');
});

it('drops an unrecognised challengeType for direct callers', () => {
const result = normalizeHintRequest(
validBody({ challengeType: 'ruby' as HintRequestBody['challengeType'] }),
);

expect(result.challengeType).toBeUndefined();
});

it('retains defensive guards for direct callers', () => {
expect(() => normalizeHintRequest(validBody({ description: '' }))).toThrow(
InputValidationError,
Expand Down
46 changes: 32 additions & 14 deletions src/lib/formatHintOutput.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,48 @@
import sanitizeHtml from 'sanitize-html';

export const MAX_HINT_CODE_POINTS = 1000;

const ELLIPSIS_CHARS = 3;
const MAX_CHARS_PER_ESCAPED_CODE_POINT = 5;

export const MAX_HINT_RESPONSE_CHARS =
MAX_HINT_CODE_POINTS * MAX_CHARS_PER_ESCAPED_CODE_POINT + ELLIPSIS_CHARS;

const BARE_AMPERSAND = /&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#[xX][0-9a-fA-F]{1,6});)/g;
const ESCAPED_CODE_TAG = /&lt;(\/?)code\b((?:(?!&gt;).)*)&gt;/gi;

function truncateCodePoints(value: string): string {
const codePoints = Array.from(value);
if (codePoints.length <= MAX_HINT_CODE_POINTS) return value;
return `${codePoints.slice(0, MAX_HINT_CODE_POINTS).join('').trim()}...`;
}

/**
* Formats model output for the API's limited-HTML contract.
* Only attribute-free <code> elements are preserved. All other raw tags are
* escaped so code examples remain visible without becoming active HTML.
*/
function escapeMarkup(value: string): string {
return value.replace(BARE_AMPERSAND, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function restoreAttributeFreeCodeElements(escaped: string): string {
let unclosedCodeElements = 0;

const restored = escaped.replace(ESCAPED_CODE_TAG, (match: string, closingSlash: string) => {
if (closingSlash) {
if (unclosedCodeElements === 0) return match;
unclosedCodeElements -= 1;
return '</code>';
}
unclosedCodeElements += 1;
return '<code>';
});

return restored + '</code>'.repeat(unclosedCodeElements);
}

export function formatHintOutput(hint: string): string {
const normalized = hint.trim().replace(/\s+/gu, ' ');
if (!normalized) return '';

const truncated = truncateCodePoints(normalized);
return sanitizeHtml(truncated, {
allowedTags: ['code'],
allowedAttributes: {},
disallowedTagsMode: 'escape',
})
.trim()
.replace(/\s+/gu, ' ');
const escaped = escapeMarkup(truncated);

return restoreAttributeFreeCodeElements(escaped).trim().replace(/\s+/gu, ' ');
}

export default formatHintOutput;
23 changes: 20 additions & 3 deletions src/lib/normalizeHintRequest.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { InputValidationError } from '../errors/inputValidationError';
import type { HintRequestBody, NormalizedHintRequest } from '../types/hint';
import {
CHALLENGE_TYPES,
type ChallengeType,
type HintRequestBody,
type NormalizedHintRequest,
} from '../types/hint';

function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

function isValidChallengeType(value: unknown): value is ChallengeType {
return typeof value === 'string' && CHALLENGE_TYPES.includes(value as ChallengeType);
}

const PROMPT_FRAME_TAGS = /<\s*\/?\s*(?:challenge_description|student_code|failing_test)\s*>/gi;

function stripPromptFrameTags(value: string): string {
return value.replace(PROMPT_FRAME_TAGS, '');
let previous: string;
let stripped = value;

do {
previous = stripped;
stripped = stripped.replace(PROMPT_FRAME_TAGS, '');
} while (stripped !== previous);

return stripped;
}

/**
Expand Down Expand Up @@ -47,7 +64,7 @@ export function normalizeHintRequest(raw: HintRequestBody): NormalizedHintReques

return {
userId: userId.trim(),
challengeType,
challengeType: isValidChallengeType(challengeType) ? challengeType : undefined,
description: stripPromptFrameTags(description.trim()),
userInput: stripPromptFrameTags(effectiveUserInput.trim()),
seed: stripPromptFrameTags(typeof seed === 'string' ? seed.trim() : ''),
Expand Down