-
Notifications
You must be signed in to change notification settings - Fork 0
test: pin contracts a framework swap can silently move #847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
juliopolycarpo
merged 7 commits into
main
from
test/characterize-framework-migration-contracts
Aug 13, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
138b7bf
test(shared): lock composed schema and operator behavior
juliopolycarpo f3cd07c
test(api): lock request lifecycle and guard scope
juliopolycarpo 72f04de
test(api): lock the public error contract
juliopolycarpo 5bda89e
test(api): lock plugin, OpenAPI and static precedence
juliopolycarpo 94dfd66
test(api): close the socket protocol gaps
juliopolycarpo 5ff4140
test(frontend): lock the Eden client type seam
juliopolycarpo b6284b5
test: close false-green characterization gaps
juliopolycarpo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
348 changes: 348 additions & 0 deletions
348
apps/api/tests/integration/routes/app-plugin-contract.integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,348 @@ | ||
| /** | ||
| * The plugin surface `app.ts` composes, asserted over the real application. | ||
| * | ||
| * CORS, OpenAPI, the two file-serving prefixes, the root WebSocket transport, | ||
| * and the request logger are all configured once at the top of `app.ts` and | ||
| * never referenced again. Nothing downstream would fail to compile if a plugin | ||
| * started answering on a different path, dropped an origin check, stopped | ||
| * emitting an operation, or dropped the transport caps — the only evidence is | ||
| * the HTTP or WebSocket response, so that is what these assert. | ||
| * | ||
| * The OpenAPI document doubles as a route inventory. A framework change that | ||
| * silently drops a route would leave every other suite green, because a suite | ||
| * only covers routes it already knows about; comparing the generated | ||
| * path-to-methods map against a checked-in fixture is what makes a missing | ||
| * route visible. | ||
| */ | ||
|
|
||
| import { describe, expect, it } from 'bun:test'; | ||
| import { join } from 'node:path'; | ||
| import { app } from '../../../src/app'; | ||
| import { REALTIME_WEBSOCKET_OPTIONS } from '../../../src/modules/realtime/http/realtime-routes'; | ||
|
|
||
| const ROUTE_INVENTORY_FIXTURE = join( | ||
| import.meta.dir, | ||
| '../../support/fixtures/openapi-route-inventory.json' | ||
| ); | ||
|
|
||
| type OpenApiOperation = { | ||
| operationId?: string; | ||
| parameters?: { name: string; in: string; required?: boolean; schema?: Record<string, unknown> }[]; | ||
| requestBody?: { | ||
| required?: boolean; | ||
| content: Record<string, { schema: Record<string, unknown> }>; | ||
| }; | ||
| responses?: Record<string, { content?: Record<string, { schema?: Record<string, unknown> }> }>; | ||
| }; | ||
| type OpenApiDocument = { | ||
| openapi: string; | ||
| info: { title: string; version: string }; | ||
| paths: Record<string, Record<string, OpenApiOperation>>; | ||
| }; | ||
|
|
||
| const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace']); | ||
|
|
||
| let cachedDocument: OpenApiDocument | null = null; | ||
|
|
||
| async function openApiDocument(): Promise<OpenApiDocument> { | ||
| if (cachedDocument) return cachedDocument; | ||
| const response = await app.handle(new Request('http://localhost/scalar/json')); | ||
| expect(response.status).toBe(200); | ||
| cachedDocument = (await response.json()) as OpenApiDocument; | ||
| return cachedDocument; | ||
| } | ||
|
|
||
| /** | ||
| * Reduce the document to `path -> sorted methods`. | ||
| * | ||
| * Descriptions, schema bodies, and generator ordering are all deliberately | ||
| * discarded: they churn on every unrelated edit, and the question this fixture | ||
| * answers is only "is every route still published under the same path and | ||
| * method". | ||
| * | ||
| * `/uploads` is excluded because `@elysiajs/static` derives its routes from | ||
| * whatever is on disk when the plugin is registered — it publishes a `/uploads/*` | ||
| * wildcard when the directory is absent and nothing when it is populated. That | ||
| * makes its footprint a property of the machine rather than of the API, so | ||
| * pinning it would fail on any checkout whose uploads directory differs. The | ||
| * prefix's real behavior is asserted separately below and in | ||
| * `tests/unit/server/frontend-static.test.ts`. | ||
| */ | ||
| function routeInventory(document: OpenApiDocument): Record<string, string[]> { | ||
| const inventory: Record<string, string[]> = {}; | ||
| for (const path of Object.keys(document.paths).sort()) { | ||
| if (path === '/uploads' || path.startsWith('/uploads/')) continue; | ||
| const methods = Object.keys(document.paths[path] ?? {}) | ||
| .filter((key) => HTTP_METHODS.has(key.toLowerCase())) | ||
| .map((key) => key.toLowerCase()) | ||
| .sort(); | ||
| inventory[path] = methods; | ||
| } | ||
| return inventory; | ||
| } | ||
|
|
||
| describe('CORS policy', () => { | ||
| it('grants a configured origin credentialed access with the configured verbs', async () => { | ||
| const response = await app.handle( | ||
| new Request('http://localhost/api/health', { | ||
| method: 'OPTIONS', | ||
| headers: { | ||
| Origin: 'http://localhost:5173', | ||
| 'Access-Control-Request-Method': 'POST', | ||
| 'Access-Control-Request-Headers': 'content-type', | ||
| }, | ||
| }) | ||
| ); | ||
|
|
||
| expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); | ||
| expect(response.headers.get('access-control-allow-credentials')).toBe('true'); | ||
| expect(response.headers.get('access-control-allow-methods')).toBe( | ||
| 'GET, POST, PUT, DELETE, OPTIONS' | ||
| ); | ||
| expect(response.headers.get('access-control-allow-headers')).toBe( | ||
| 'Content-Type, Authorization, x-api-key' | ||
| ); | ||
| expect(response.headers.get('vary')).toContain('Origin'); | ||
| }); | ||
|
|
||
| it('withholds the allow-origin header from an origin that is not configured', async () => { | ||
| const response = await app.handle( | ||
| new Request('http://localhost/api/health', { | ||
| method: 'OPTIONS', | ||
| headers: { | ||
| Origin: 'https://attacker.example', | ||
| 'Access-Control-Request-Method': 'POST', | ||
| }, | ||
| }) | ||
| ); | ||
|
|
||
| // Absence is the refusal: a browser only releases a credentialed response | ||
| // when the origin is echoed back. `allow-credentials` alone grants nothing, | ||
| // so the assertion that matters is that the echo is missing. | ||
| expect(response.headers.get('access-control-allow-origin')).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('OpenAPI document', () => { | ||
| it('serves the Scalar UI and the generated document', async () => { | ||
| // The exact paths the current plugin publishes. PR-002-style plugin swaps | ||
| // must prove these rather than assume them: `/scalar/json` in particular is | ||
| // a plugin-owned convention, not something this repository declares. | ||
| const ui = await app.handle(new Request('http://localhost/scalar')); | ||
| expect(ui.status).toBe(200); | ||
| expect(ui.headers.get('content-type')).toContain('text/html'); | ||
|
|
||
| const document = await app.handle(new Request('http://localhost/scalar/json')); | ||
| expect(document.status).toBe(200); | ||
| expect(document.headers.get('content-type')).toContain('application/json'); | ||
| }); | ||
|
|
||
| it('keeps the declared document metadata', async () => { | ||
| const document = await openApiDocument(); | ||
|
|
||
| expect(document.openapi.startsWith('3.')).toBe(true); | ||
| expect(document.info.title).toBe('MangoStudio API'); | ||
| expect(document.info.version).toBe('1.0.0'); | ||
| }); | ||
|
|
||
| it('publishes exactly the recorded set of paths and methods', async () => { | ||
| const document = await openApiDocument(); | ||
| const expected = (await Bun.file(ROUTE_INVENTORY_FIXTURE).json()) as Record<string, string[]>; | ||
|
|
||
| // Adding or removing a route updates the fixture in the same commit: apply | ||
| // the failure diff to `openapi-route-inventory.json`, then `bun run fix` to | ||
| // format it. A diff nobody intended is a route the framework stopped | ||
| // publishing — which no other suite can see, because a suite only covers | ||
| // the routes it already knows about. | ||
| expect(routeInventory(document)).toEqual(expected); | ||
| }); | ||
|
|
||
| it('describes a path parameter with its constraints', async () => { | ||
| const document = await openApiDocument(); | ||
| const operation = document.paths['/api/api-keys/{id}']?.delete; | ||
|
|
||
| expect(operation?.parameters).toContainEqual( | ||
| expect.objectContaining({ | ||
| name: 'id', | ||
| in: 'path', | ||
| required: true, | ||
| schema: expect.objectContaining({ type: 'string', minLength: 1 }), | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it('describes a multipart file body with its declared limits', async () => { | ||
| const document = await openApiDocument(); | ||
| const body = document.paths['/api/upload/chat']?.post?.requestBody; | ||
| const multipart = body?.content['multipart/form-data']?.schema as | ||
| | { required?: string[]; properties?: Record<string, Record<string, unknown>> } | ||
| | undefined; | ||
|
|
||
| expect(body?.required).toBe(true); | ||
| expect(multipart?.required).toEqual(['chatId', 'file']); | ||
| expect(multipart?.properties?.file).toMatchObject({ format: 'binary', maxSize: '20m' }); | ||
| }); | ||
|
|
||
| it('describes a JSON response body with its required fields', async () => { | ||
| const document = await openApiDocument(); | ||
| const schema = document.paths['/api/upload/chat']?.post?.responses?.['200']?.content?.[ | ||
| 'application/json' | ||
| ]?.schema as { required?: string[] } | undefined; | ||
|
|
||
| expect(schema?.required).toEqual(['attachment']); | ||
| }); | ||
|
|
||
| it('describes error responses with the shared ApiErrorResponse shape', async () => { | ||
| const document = await openApiDocument(); | ||
|
|
||
| for (const status of ['400', '401']) { | ||
| const schema = document.paths['/api/api-keys/{id}']?.delete?.responses?.[status]?.content?.[ | ||
| 'application/json' | ||
| ]?.schema as { required?: string[]; properties?: Record<string, unknown> } | undefined; | ||
|
|
||
| // `ApiErrorResponse` is `{ error, code?, details? }`. Published inline | ||
| // rather than as a `$ref`, so the shape is asserted directly. | ||
| expect(schema?.required).toEqual(['error']); | ||
| expect(Object.keys(schema?.properties ?? {})).toEqual(['error', 'code', 'details']); | ||
| } | ||
| }); | ||
|
|
||
| it('gives every published operation an operation id', async () => { | ||
| const document = await openApiDocument(); | ||
| const missing: string[] = []; | ||
|
|
||
| for (const [path, operations] of Object.entries(document.paths)) { | ||
| for (const [method, operation] of Object.entries(operations)) { | ||
| if (!HTTP_METHODS.has(method.toLowerCase())) continue; | ||
| if (!operation.operationId) missing.push(`${method.toUpperCase()} ${path}`); | ||
| } | ||
| } | ||
|
|
||
| expect(missing).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('file-serving prefixes', () => { | ||
| it('answers a missing generated image with 404 rather than an SPA shell', async () => { | ||
| const response = await app.handle(new Request('http://localhost/images/not-there.png')); | ||
|
|
||
| expect(response.status).toBe(404); | ||
| expect(response.headers.get('content-type') ?? '').not.toContain('text/html'); | ||
| }); | ||
|
|
||
| it('refuses a generated-image path that escapes the images directory', async () => { | ||
| // `new Request` canonicalizes `/images/../../etc/passwd` to `/etc/passwd` | ||
| // before `app.handle` sees it, so that form never reaches `/images/*`. | ||
| // Encoded separators keep the `/images/` prefix and still decode to a | ||
| // traversal once the handler resolves the splat. | ||
| const response = await app.handle(new Request('http://localhost/images/..%2f..%2fetc/passwd')); | ||
|
|
||
| expect(response.status).toBe(404); | ||
| expect(await response.text()).toBe('Not found'); | ||
| expect(response.headers.get('content-type') ?? '').not.toContain('text/html'); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| it('leaves the uploads prefix unclaimed by any API route', async () => { | ||
| // `staticPlugin` enumerates its directory once at registration, so serving | ||
| // a real file is covered where the plugin can be mounted over a fixture | ||
| // directory (`frontend-static.test.ts`). What the composed app has to | ||
| // guarantee is narrower and just as easy to break: nothing else answers | ||
| // here, so a real upload is never shadowed. | ||
| const response = await app.handle(new Request('http://localhost/uploads/not-there.png')); | ||
|
|
||
| expect(response.status).toBe(404); | ||
| expect(response.headers.get('content-type') ?? '').not.toContain('text/html'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('request logging', () => { | ||
| it('logs API requests and stays silent for frontend assets', async () => { | ||
| const previous = process.env.MANGOSTUDIO_DIAGNOSTIC_LOGS; | ||
| const originalWarn = console.warn; | ||
| const captured: string[] = []; | ||
|
|
||
| process.env.MANGOSTUDIO_DIAGNOSTIC_LOGS = '1'; | ||
| console.warn = (line: unknown) => captured.push(String(line)); | ||
|
|
||
| try { | ||
| await app.handle(new Request('http://localhost/api/health')); | ||
| await app.handle(new Request('http://localhost/assets/index-AbCd1234.js')); | ||
| await app.handle(new Request('http://localhost/uploads/anything.png')); | ||
| await app.handle(new Request('http://localhost/some-spa-route')); | ||
| } finally { | ||
| console.warn = originalWarn; | ||
| if (previous === undefined) delete process.env.MANGOSTUDIO_DIAGNOSTIC_LOGS; | ||
| else process.env.MANGOSTUDIO_DIAGNOSTIC_LOGS = previous; | ||
| } | ||
|
|
||
| // The `onRequest` hook filters on the path prefix. Without the filter every | ||
| // asset fetch in a browser session writes a structured log line, which is | ||
| // what made the logs unusable before it was added. | ||
| const logged = captured | ||
| .map((line) => { | ||
| try { | ||
| return JSON.parse(line) as { scope?: string; metadata?: { path?: string } }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }) | ||
| .filter((entry): entry is { scope?: string; metadata?: { path?: string } } => entry !== null) | ||
| .filter((entry) => entry.scope === 'request') | ||
| .map((entry) => entry.metadata?.path); | ||
|
|
||
| expect(logged).toEqual(['/api/health']); | ||
| }); | ||
| }); | ||
|
|
||
| describe('root WebSocket transport', () => { | ||
| it('applies the payload cap on the exported application', async () => { | ||
| // The transport caps are a constructor option on this instance, not a | ||
| // route plugin. `app.handle` cannot see them, so the assertion has to | ||
| // listen on the composed app rather than rebuild a root that happens to | ||
| // pass the same object. | ||
| app.listen(0); | ||
| const port = (app.server as { port?: number } | null)?.port; | ||
| expect(port).toBeNumber(); | ||
|
|
||
| try { | ||
| const signup = await fetch(`http://127.0.0.1:${port}/api/auth/sign-up/email`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| email: `ws-cap-${crypto.randomUUID()}@mangostudio.test`, | ||
| password: 'correct-horse-battery-staple', | ||
| name: 'Payload Cap Tester', | ||
| }), | ||
| }); | ||
| expect(signup.status).toBe(200); | ||
| const cookie = signup.headers | ||
| .getSetCookie() | ||
| .map((value) => value.split(';', 1)[0]) | ||
| .join('; '); | ||
| expect(cookie).not.toBe(''); | ||
|
|
||
| const socket = new WebSocket(`ws://127.0.0.1:${port}/api/ws`, { | ||
| headers: { Cookie: cookie }, | ||
| }); | ||
| const closed = new Promise<CloseEvent>((resolve) => { | ||
| socket.addEventListener('close', (event) => resolve(event as CloseEvent), { once: true }); | ||
| }); | ||
| const ready = new Promise<void>((resolve, reject) => { | ||
| socket.addEventListener('error', () => reject(new Error('WebSocket failed to open')), { | ||
| once: true, | ||
| }); | ||
| socket.addEventListener('message', (event) => { | ||
| const message = JSON.parse(String(event.data)) as { type?: string }; | ||
| if (message.type === 'ready') resolve(); | ||
| }); | ||
| }); | ||
| await ready; | ||
|
|
||
| socket.send('x'.repeat(REALTIME_WEBSOCKET_OPTIONS.maxPayloadLength + 1)); | ||
|
|
||
| expect((await closed).code).not.toBe(1000); | ||
| } finally { | ||
| void app.server?.stop(true); | ||
| } | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.