diff --git a/hono-api/src/index.ts b/hono-api/src/index.ts index 489a848a..2878bd2e 100644 --- a/hono-api/src/index.ts +++ b/hono-api/src/index.ts @@ -16,7 +16,7 @@ app.use('*', logger()); app.use( '*', cors({ - origin: ['http://localhost:5173', 'http://localhost:3000'], + origin: ['http://localhost:3030', 'http://localhost:5173', 'http://localhost:3000'], allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], }) diff --git a/specs/core/api/openapi.yaml b/specs/core/api/openapi.yaml index 0119f410..4fe821b4 100644 --- a/specs/core/api/openapi.yaml +++ b/specs/core/api/openapi.yaml @@ -702,7 +702,7 @@ components: nullable: true specialRequirements: type: string - maxLength: 1000 + maxLength: 5000 nullable: true salaryMin: type: integer @@ -845,7 +845,7 @@ components: type: boolean specialRequirements: type: string - maxLength: 1000 + maxLength: 5000 salaryMin: type: integer minimum: 0 @@ -899,7 +899,7 @@ components: nullable: true specialRequirements: type: string - maxLength: 1000 + maxLength: 5000 nullable: true salaryMin: type: integer diff --git a/specs/core/domain/entities.md b/specs/core/domain/entities.md index 43f450ad..2d6f7a57 100644 --- a/specs/core/domain/entities.md +++ b/specs/core/domain/entities.md @@ -26,7 +26,7 @@ A record representing a single job application submitted by the user. | skillsMatch | integer | no | - | 1-5 scale | | jobSource | JobSource | no | - | See [enums.md](enums.md) | | coverLetterRequired | boolean | no | - | true/false | -| specialRequirements | string | no | - | Max 1000 characters | +| specialRequirements | string | no | - | Max 5000 characters | | salaryMin | integer | no | - | Positive number | | salaryMax | integer | no | - | Positive number, >= salaryMin | | notes | string | no | - | Max 5000 characters | diff --git a/specs/core/domain/validation-rules.md b/specs/core/domain/validation-rules.md index 03529182..846ab789 100644 --- a/specs/core/domain/validation-rules.md +++ b/specs/core/domain/validation-rules.md @@ -19,7 +19,7 @@ This document defines validation rules for domain entities, independent of any v |-------|-----|-----|---------------| | companyName | 1 | 200 | "Company name must be between 1 and 200 characters" | | positionTitle | 1 | 200 | "Position title must be between 1 and 200 characters" | -| specialRequirements | - | 1000 | "Special requirements must not exceed 1000 characters" | +| specialRequirements | - | 5000 | "Special requirements must not exceed 5000 characters" | | notes | - | 5000 | "Notes must not exceed 5000 characters" | ### URL Validation diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 838e9cec..d3eb3bbf 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -8,7 +8,11 @@ Shared API and E2E tests run against multiple stacks. - Shared API Jest tests mutate one schema per stack. Keep `--runInBand` in commands that run `tests/api`, including `scripts/run-api-tests.sh` and direct `test:api:` scripts. - `run-api-tests.sh all` continues on failure and reports every failed stack. - Go API and Spring API require all non-optional fields in PATCH requests. Include `companyName` + `positionTitle` for application PATCH, and `name` + `order` for interview stage PATCH. -- Update stack flags in `tests/api/helpers.ts` when adding a stack: `validatesDates`, `hasInterviewStageDates`, and similar contract capability flags. +- Update stack flags in `tests/api/helpers.ts` when adding a stack: `validatesDates`, `hasInterviewStageDates`, `expectedAllowedOrigin`, and similar contract capability flags. + +### Cross-stack contract gaps (deliberately not enforced) + +- **Stage UUID stability across history restore is not part of the cross-stack contract.** Only `rails-api` preserves stage IDs across snapshot restore (its `ApplicationRestoreService` does `destroy_all` + recreate-with-original-UUID). Other stacks may legitimately mint new stage IDs on restore, treating restored stages as new entities. Clients that need stable stage IDs across restore should query the history endpoint, not depend on stage IDs surviving. Do not add a shared `tests/api/` assertion for stage-ID preservation. ## E2E Tests diff --git a/tests/api/application-crud.test.ts b/tests/api/application-crud.test.ts index 7d2c56d2..458a248c 100644 --- a/tests/api/application-crud.test.ts +++ b/tests/api/application-crud.test.ts @@ -158,4 +158,18 @@ describe.each(getTargetStacks(ALL_STACKS))('Application CRUD ($name)', ({ baseUr const data: ListResponse = await res.json(); expect(data.items.length).toBeLessThanOrEqual(2); }); + + // Locks the cross-stack contract for `specialRequirements` at maxLength: 5000. + // Implementations had drifted to 5000 while openapi.yaml still said 1000; + // the spec was raised to 5000 (it's freeform notes, not user-supplied query + // input). 4000 chars must be accepted by every stack. + it('POST /applications with 4000-char specialRequirements → 201', async () => { + const app = await createApp({ + companyName: 'API: Long Special Requirements Corp', + positionTitle: 'Engineer', + specialRequirements: 'x'.repeat(4000), + }); + createdIds.push(app.id); + expect(app.id).toBeTruthy(); + }); }); diff --git a/tests/api/cors.test.ts b/tests/api/cors.test.ts new file mode 100644 index 00000000..26c1ba84 --- /dev/null +++ b/tests/api/cors.test.ts @@ -0,0 +1,34 @@ +// CORS preflight regression test +// +// Every stack ships its own CORS middleware so the dev UI on a different +// localhost port can call the API. Without a shared test, accidental removal +// or misconfiguration only surfaces when a developer notices their browser +// failing in the browser console. +// +// Asserts: a preflight (OPTIONS with `Origin` and `Access-Control-Request-Method`) +// returns 2xx and an `Access-Control-Allow-Origin` header that admits the +// configured dev-UI origin — either by echoing it or by allowing all (`*`). +// +// Runs against all stacks when API_URL is unset, or a single stack when API_URL is set. + +import { ALL_STACKS, getTargetStacks } from './helpers'; + +describe.each(getTargetStacks(ALL_STACKS))('CORS preflight ($name)', ({ baseUrl, expectedAllowedOrigin }) => { + it('responds to OPTIONS preflight with an Access-Control-Allow-Origin header that admits the dev UI', async () => { + const res = await fetch(`${baseUrl}/applications`, { + method: 'OPTIONS', + headers: { + Origin: expectedAllowedOrigin, + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'content-type', + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(200); + expect(res.status).toBeLessThan(300); + + const allowOrigin = res.headers.get('access-control-allow-origin'); + expect(allowOrigin).not.toBeNull(); + expect([expectedAllowedOrigin, '*']).toContain(allowOrigin); + }); +}); diff --git a/tests/api/helpers.ts b/tests/api/helpers.ts index db776a53..892006f6 100644 --- a/tests/api/helpers.ts +++ b/tests/api/helpers.ts @@ -1,15 +1,23 @@ +// `expectedAllowedOrigin` is the dev-UI origin paired with each API stack — +// used by `cors.test.ts` to verify the preflight regression-tests its dev-UI +// allowlist. For stacks with permissive CORS (e.g., Express's bare `cors()`), +// any origin is accepted and the response is `Access-Control-Allow-Origin: *`; +// for explicit allowlists, the origin must be in the allowlist. Use the actual +// paired UI's dev port — using a different known-allowed origin (e.g. :5173) +// would let the test pass even when the real dev UI is blocked, defeating the +// regression goal. export const ALL_STACKS = [ - { name: 'express-api', baseUrl: 'http://localhost:3001', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'koa-api', baseUrl: 'http://localhost:5010', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'nuxt-api', baseUrl: 'http://localhost:5040/api', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false }, - { name: 'hono-api', baseUrl: 'http://localhost:5030', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'fastapi', baseUrl: 'http://localhost:5160', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'nest-api', baseUrl: 'http://localhost:5050', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'go-api', baseUrl: 'http://localhost:5070', validatesDates: false, hasInterviewStageDates: false, hasStageHistory: false }, - { name: 'spring-api', baseUrl: 'http://localhost:8080/api', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'yoga-api', baseUrl: 'http://localhost:5080/api', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false }, - { name: 'lambda-api', baseUrl: 'http://localhost:5090', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, - { name: 'rails-api', baseUrl: 'http://localhost:5180', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'express-api', baseUrl: 'http://localhost:3001', expectedAllowedOrigin: 'http://localhost:3000', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'koa-api', baseUrl: 'http://localhost:5010', expectedAllowedOrigin: 'http://localhost:3010', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'nuxt-api', baseUrl: 'http://localhost:5040/api', expectedAllowedOrigin: 'http://localhost:3020', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false }, + { name: 'hono-api', baseUrl: 'http://localhost:5030', expectedAllowedOrigin: 'http://localhost:3030', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'fastapi', baseUrl: 'http://localhost:5160', expectedAllowedOrigin: 'http://localhost:3040', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'nest-api', baseUrl: 'http://localhost:5050', expectedAllowedOrigin: 'http://localhost:3050', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'go-api', baseUrl: 'http://localhost:5070', expectedAllowedOrigin: 'http://localhost:3060', validatesDates: false, hasInterviewStageDates: false, hasStageHistory: false }, + { name: 'spring-api', baseUrl: 'http://localhost:8080/api', expectedAllowedOrigin: 'http://localhost:3070', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'yoga-api', baseUrl: 'http://localhost:5080/api', expectedAllowedOrigin: 'http://localhost:3080', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false }, + { name: 'lambda-api', baseUrl: 'http://localhost:5090', expectedAllowedOrigin: 'http://localhost:3090', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true }, + { name: 'rails-api', baseUrl: 'http://localhost:5180', expectedAllowedOrigin: 'http://localhost:3000', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true }, ]; export const CSV_STACKS = ALL_STACKS.filter(s => @@ -33,5 +41,5 @@ export function getTargetStacks(stacks: typeof ALL_STACKS): typeof ALL_STACKS { const stackName = process.env.STACK_NAME; const match = stackName ? ALL_STACKS.find(s => s.name === stackName) : undefined; if (stackName && !stacks.some(s => s.name === stackName)) return []; - return [{ ...(match ?? { name: stackName ?? 'target', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: false }), baseUrl: url }]; + return [{ ...(match ?? { name: stackName ?? 'target', expectedAllowedOrigin: 'http://localhost:3000', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: false }), baseUrl: url }]; }