Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ apps/web/vendor/
apps/mcp/server.bundle.js
apps/web/.vercel/
package-lock.json
# Plan/spec documents contain embedded TypeScript pseudo-code that prettier
# mis-parses into semantically-broken output (e.g. `{ a: 'x' | 'y', b }` is
# parsed as a comma expression). Keep them author-formatted.
docs/superpowers/plans/
docs/superpowers/specs/
192 changes: 185 additions & 7 deletions apps/api/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ function fakePage(
overrides: Partial<{
id: string;
spec: unknown;
format: 'a2ui' | 'html';
state: 'open' | 'submitted' | 'received';
result: unknown;
}> = {},
) {
return {
id: overrides.id ?? 'aabbccddeeff00112233445566778899',
spec: overrides.spec ?? { anything: 1 },
format: overrides.format ?? ('a2ui' as const),
state: overrides.state ?? 'open',
result: overrides.result ?? null,
createdAt: Date.now(),
Expand Down Expand Up @@ -103,7 +105,7 @@ describe('POST /new', () => {
expect(calledPage.state).toBe('open');
});

it('rejects bodies over 256 KB with 413', async () => {
it('rejects A2UI bodies over 256 KB with 413 (post-parse cap)', async () => {
const body = JSON.stringify({ spec: 'x'.repeat(300_000) });
const res = await app.fetch(
new Request(`${BASE}/new`, {
Expand All @@ -115,13 +117,13 @@ describe('POST /new', () => {
expect(res.status).toBe(413);
const resBody = await json(res);
expect(resBody.error).toBe('payload_too_large');
expect(resBody.max_bytes).toBe(MAX_BODY_BYTES);
expect(resBody.max_bytes).toBe(256_000);
expect(resBody.format).toBe('a2ui');
expect(typeof resBody.message).toBe('string');
expect(resBody.message as string).toContain(String(MAX_BODY_BYTES));
expect(db.insertPage).not.toHaveBeenCalled();
});

it('accepts a body just under 256 KB', async () => {
it('accepts an A2UI body just under 256 KB', async () => {
const body = JSON.stringify({ spec: 'x'.repeat(250_000) });
const res = await app.fetch(
new Request(`${BASE}/new`, {
Expand All @@ -133,7 +135,7 @@ describe('POST /new', () => {
expect(res.status).toBe(201);
});

it('413 body.message references the byte limit', async () => {
it('413 body.message references the A2UI byte limit', async () => {
const body = JSON.stringify({ spec: 'x'.repeat(300_000) });
const res = await app.fetch(
new Request(`${BASE}/new`, {
Expand All @@ -145,7 +147,102 @@ describe('POST /new', () => {
expect(res.status).toBe(413);
const resBody = await json(res);
expect(typeof resBody.message).toBe('string');
expect(resBody.message as string).toContain(String(MAX_BODY_BYTES));
expect(resBody.message as string).toContain('256');
});

it('bodyLimit middleware rejects any body > 1 MB with 413', async () => {
// 1 MB is the absolute bodyLimit cap (per spec, HTML's true ceiling).
// Stuff in a JSON string that pushes the wire body past 1 MB.
const body = JSON.stringify({ spec: 'x'.repeat(1_050_000) });
const res = await app.fetch(
new Request(`${BASE}/new`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
}),
);
expect(res.status).toBe(413);
const resBody = await json(res);
expect(resBody.error).toBe('payload_too_large');
expect(resBody.max_bytes).toBe(MAX_BODY_BYTES);
expect(MAX_BODY_BYTES).toBe(1_000_000);
});
});

// ---------------------------------------------------------------------------
// POST /new with format=html
// ---------------------------------------------------------------------------

describe('POST /new with format=html', () => {
it('accepts an HTML payload and returns 201 with { id, url, expires_at }', async () => {
const res = await app.fetch(
req('POST', '/new', {
format: 'html',
spec: '<div><h1>Hello</h1><p>World</p></div>',
}),
);
expect(res.status).toBe(201);
const body = await json(res);
expect((body.id as string) ?? '').toMatch(/^[a-f0-9]{32}$/);
expect(typeof body.url).toBe('string');
expect(typeof body.expires_at).toBe('number');
});

it('passes format=html and a sanitized spec to db.insertPage', async () => {
await app.fetch(
req('POST', '/new', {
format: 'html',
spec: '<div>safe</div><script>alert(1)</script>',
}),
);
expect(db.insertPage).toHaveBeenCalledOnce();
const [page] = vi.mocked(db.insertPage).mock.calls[0];
expect(page.format).toBe('html');
expect(typeof page.spec).toBe('string');
expect(page.spec as string).not.toContain('<script');
expect(page.spec as string).toContain('<div>safe</div>');
});

it('rejects HTML payloads > 1 MB with 413 payload_too_large', async () => {
const big = 'a'.repeat(1_000_001);
const res = await app.fetch(req('POST', '/new', { format: 'html', spec: big }));
// The wire body (with JSON wrapping) exceeds the 1 MB bodyLimit, so the
// bodyLimit middleware fires before Zod parsing and returns 413.
expect(res.status).toBe(413);
const body = await json(res);
expect(body.error).toBe('payload_too_large');
});

it('accepts A2UI payloads with implicit default format (backwards compat)', async () => {
const res = await app.fetch(
req('POST', '/new', { spec: [{ createSurface: { surfaceId: 'm' } }] }),
);
expect(res.status).toBe(201);
});

it('rejects A2UI payloads > 256 KB even when body limit allows up to 1 MB', async () => {
// Use a value below the 1 MB bodyLimit but above the 256 KB A2UI cap.
const big = 'x'.repeat(300_000);
const res = await app.fetch(req('POST', '/new', { spec: big }));
expect(res.status).toBe(413);
const body = await json(res);
expect(body.error).toBe('payload_too_large');
expect(body.format).toBe('a2ui');
});

it('returns 400 sanitized_empty when sanitization yields empty output', async () => {
// Pure forbidden tags — DOMPurify strips everything, leaving an empty
// string. The handler must reject with a clear error rather than store
// an empty HTML page.
const res = await app.fetch(
req('POST', '/new', { format: 'html', spec: '<script>alert(1)</script>' }),
);
expect(res.status).toBe(400);
const body = await json(res);
expect(body.error).toBe('sanitized_empty');
expect(body.format).toBe('html');
expect(typeof body.message).toBe('string');
expect(db.insertPage).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -196,6 +293,10 @@ describe('POST /:id/result', () => {
// returned 409 "already submitted" for a page that was merely expired.
// The fixed SELECT adds `expires_at > now()`, making expired rows return
// 'not_found' → 404, which is the correct user-facing response.
// The handler reads the page first via getActivePage for the format guard;
// mock it to return an active a2ui page so submitPage's 'not_found' outcome
// is what we exercise here (e.g. row expired between the two reads).
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakePage());
(db.submitPage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ kind: 'not_found' });
const res = await app.fetch(req('POST', `/${UNKNOWN_ID}/result`, validAction));
expect(res.status).toBe(404);
Expand All @@ -205,6 +306,7 @@ describe('POST /:id/result', () => {

it('returns 200 and calls db.submitPage when page is open', async () => {
const page = fakePage();
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
(db.submitPage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
kind: 'ok',
createdAt: new Date(),
Expand All @@ -218,6 +320,7 @@ describe('POST /:id/result', () => {

it('returns 409 on conflict (already submitted)', async () => {
const page = fakePage({ state: 'submitted' });
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
(db.submitPage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ kind: 'conflict' });
const res = await app.fetch(req('POST', `/${page.id}/result`, validAction));
expect(res.status).toBe(409);
Expand All @@ -229,14 +332,16 @@ describe('POST /:id/result', () => {

it('409 conflict body.message mentions creating a new page', async () => {
const page = fakePage({ state: 'submitted' });
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
(db.submitPage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ kind: 'conflict' });
const res = await app.fetch(req('POST', `/${page.id}/result`, validAction));
const body = await json(res);
expect(body.message as string).toContain('new page');
});

it('returns 400 for result body with name: "" (empty name)', async () => {
// Validation happens before db call, so no need to stub submitPage
// Page must exist (a2ui) so we reach the body-parse stage.
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakePage());
const res = await app.fetch(req('POST', `/${UNKNOWN_ID}/result`, { name: '', surfaceId: 'x' }));
expect(res.status).toBe(400);
const body = await json(res);
Expand All @@ -253,18 +358,21 @@ describe('GET /:id/result', () => {
(db.fetchAndAdvanceResult as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
stateAtRead: 'open',
result: null,
format: 'a2ui',
});
const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`));
expect(res.status).toBe(200);
const body = await json(res);
expect(body.state).toBe('open');
expect(body.result).toBeNull();
expect(body.format).toBe('a2ui');
});

it('returns submitted result after POST /:id/result', async () => {
(db.fetchAndAdvanceResult as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
stateAtRead: 'submitted',
result: validAction,
format: 'a2ui',
});
const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`));
expect(res.status).toBe(200);
Expand All @@ -279,6 +387,7 @@ describe('GET /:id/result', () => {
(db.fetchAndAdvanceResult as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
stateAtRead: 'received',
result: validAction,
format: 'a2ui',
});
const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`));
expect(res.status).toBe(200);
Expand All @@ -293,6 +402,72 @@ describe('GET /:id/result', () => {
});
});

// ---------------------------------------------------------------------------
// format echo + HTML result handling
// ---------------------------------------------------------------------------

describe('format echo and HTML result handling', () => {
it('GET /:id echoes format=html for an HTML page', async () => {
const page = fakePage({ format: 'html', spec: '<p>x</p>' });
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
const res = await app.fetch(req('GET', `/${page.id}`));
expect(res.status).toBe(200);
const body = await json(res);
expect(body.format).toBe('html');
});

it('GET /:id echoes format=a2ui for an A2UI page', async () => {
const page = fakePage({ format: 'a2ui' });
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
const res = await app.fetch(req('GET', `/${page.id}`));
expect(res.status).toBe(200);
const body = await json(res);
expect(body.format).toBe('a2ui');
});

it('GET /:id/result includes format on every response', async () => {
(db.fetchAndAdvanceResult as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
stateAtRead: 'open',
result: null,
format: 'html',
});
const res = await app.fetch(req('GET', `/${UNKNOWN_ID}/result`));
expect(res.status).toBe(200);
const body = await json(res);
expect(body.format).toBe('html');
expect(body.state).toBe('open');
expect(body.result).toBeNull();
});

it('POST /:id/result rejects HTML pages with 400 invalid_for_format', async () => {
const page = fakePage({ format: 'html', spec: '<p>x</p>' });
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
const res = await app.fetch(
req('POST', `/${page.id}/result`, { name: 'submitted', surfaceId: 'main' }),
);
expect(res.status).toBe(400);
const body = await json(res);
expect(body.error).toBe('invalid_for_format');
expect(body.format).toBe('html');
expect(db.submitPage).not.toHaveBeenCalled();
});

it('POST /:id/result still works for A2UI pages (regression)', async () => {
const page = fakePage({ format: 'a2ui' });
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(page);
(db.submitPage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
kind: 'ok',
createdAt: new Date(),
});
const res = await app.fetch(
req('POST', `/${page.id}/result`, { name: 'submitted', surfaceId: 'main' }),
);
expect(res.status).toBe(200);
const body = await json(res);
expect(body.ok).toBe(true);
});
});

// ---------------------------------------------------------------------------
// GET /health
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -479,6 +654,9 @@ describe('error handler', () => {
});

it('submitPage throw on POST /:id/result returns 500', async () => {
// Page-existence gate runs first; mock it to return an a2ui page so
// the throw on submitPage is what we actually exercise.
(db.getActivePage as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakePage());
(db.submitPage as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('db write failed'));
const res = await app.fetch(req('POST', `/${UNKNOWN_ID}/result`, validAction));
expect(res.status).toBe(500);
Expand Down
Loading
Loading