Files
dockhand-upstream/tests/openapi-body-annotations.test.ts
T
Björn StrausmannandJarek Krochmalski 33c0c47855 fix(openapi): recognize bare object type + document existing field on validate (#1532)
* fix(openapi): recognize bare `object` type in annotation parser

The @openapi annotation parser's parseMiniSchema() only recognized the
bare words integer/number/boolean/string as scalar types. Any other
bare word - including `object`, used in 16 route annotations across
the codebase (e.g. severity:object, envVars:object on
POST /api/stacks/{name}/validate) - silently fell through to the
generic string fallback, so these fields were documented in
static/openapi.json as {"type": "string"} instead of
{"type": "object"}.

Add a dedicated branch for bare `object`, producing an opaque object
schema (no declared properties, since the bare word carries no shape
information of its own - a documented shape should use the existing
{...} form).

Verified no other bare non-standard word is used in any body:/resp-*:
annotation line across the 257 route files, so this closes the gap
completely for the current codebase.

Fixes #1531

* docs(openapi): document the existing field on POST /api/stacks/{name}/validate

The handler reads body.existing (an optional boolean, undocumented)
to control self-exclusion of the stack's own running containers/ports
from the cross-stack collision checks - the exact case the endpoint's
own summary advertises as handled. Without it, a client built strictly
from the OpenAPI document has no way to learn the field exists, and
will validate an existing stack the same way as a new one, silently
reintroducing false-positive collision findings against the stack's
own containers.

Add existing:boolean to the body: annotation, plus a one-line
description explaining the true/omitted semantics.

Fixes #1530

---------

Co-authored-by: Jarek Krochmalski <jotka@users.noreply.github.com>
2026-09-06 15:51:12 +05:30

141 lines
6.1 KiB
TypeScript

// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { parseAnnotations, parseMiniSchema } from '../scripts/openapi/lib';
import { buildSpec } from '../scripts/openapi/build-spec';
// Wrap a single-method annotation block in a minimal +server.ts source.
function src(method: string, block: string): string {
return `/**\n${block}\n */\nexport const ${method}: RequestHandler = async () => new Response();\n`;
}
describe('parseMiniSchema: always terminates + bracket arrays', () => {
// A mini-schema string with the [T] bracket-array form (or any unrecognized
// char) must ALWAYS advance the parser cursor. A stuck cursor spun the drift
// gate for hours in CI. These strings are the ones that hung.
test('[string] parses as an array of string', () => {
expect(parseMiniSchema('[string]')).toEqual({ kind: 'array', items: { kind: 'string' } });
});
test('nested object with a [T] property (the exact string that hung CI)', () => {
const s = parseMiniSchema('{compose:string!, config:{disabled:[string], severity:object}}');
expect(s.kind).toBe('object');
expect((s as any).properties.config.properties.disabled).toEqual({ kind: 'array', items: { kind: 'string' } });
expect((s as any).required).toContain('compose');
});
test('[{...}]! required array of objects', () => {
const s = parseMiniSchema('{findings:[{ruleId:string!}]!}');
expect((s as any).properties.findings.kind).toBe('array');
expect((s as any).properties.findings.items.kind).toBe('object');
expect((s as any).required).toContain('findings');
});
test('garbage / stray brackets terminate (never infinite-loop)', () => {
// If any of these looped, the test process would hang instead of asserting.
expect(() => parseMiniSchema('{a:[[[')).not.toThrow();
expect(() => parseMiniSchema('[[[[[[')).not.toThrow();
expect(() => parseMiniSchema('{:::,,,}')).not.toThrow();
expect(() => parseMiniSchema('@#$%^&*')).not.toThrow();
});
test('array<T> long form still works (no regression)', () => {
expect(parseMiniSchema('array<string>')).toEqual({ kind: 'array', items: { kind: 'string' } });
});
test('bare `object` parses as an opaque object, not the string fallback', () => {
expect(parseMiniSchema('object')).toEqual({ kind: 'object', properties: {}, required: [] });
});
test('nested object with a bare `object` property (the real body: annotation on POST /api/stacks/{name}/validate — was silently "string")', () => {
// Before this fix, `severity` and `envVars` both fell through to
// `{ kind: 'string' }` because `parseType()` only recognized
// integer/number/boolean/string as bare words. `existing` (added for
// #1530) is a plain `boolean` and was never affected by this bug —
// included here only to keep this literal in sync with the real
// annotation.
const s = parseMiniSchema(
'{compose:string!, existing:boolean, config:{disabled:[string], severity:object}, envVars:object}'
);
expect((s as any).properties.config.properties.severity).toEqual({ kind: 'object', properties: {}, required: [] });
expect((s as any).properties.envVars).toEqual({ kind: 'object', properties: {}, required: [] });
expect((s as any).properties.existing).toEqual({ kind: 'boolean' });
});
});
describe('parseAnnotations: non-JSON request bodies', () => {
test('body-raw parses media type + description', () => {
const a = parseAnnotations(
src('POST', ' * @openapi\n * summary: Load an image\n * body-raw: application/x-tar The raw image tar')
);
expect(a.POST?.bodyRaw).toEqual({ mediaType: 'application/x-tar', description: 'The raw image tar' });
});
test('body-multipart parses field/type/array/required/description', () => {
const a = parseAnnotations(
src('POST', ' * @openapi\n * summary: Upload files\n * body-multipart: files:binary[]! One or more files')
);
expect(a.POST?.bodyMultipart).toEqual({
field: 'files',
type: 'binary',
array: true,
required: true,
description: 'One or more files'
});
});
test('a JSON body: still parses as before (no regression)', () => {
const a = parseAnnotations(src('POST', ' * @openapi\n * summary: X\n * body: {name:string!}'));
expect(a.POST?.body).toBeDefined();
expect(a.POST?.bodyRaw).toBeUndefined();
expect(a.POST?.bodyMultipart).toBeUndefined();
});
});
describe('buildSpec: emits requestBody for non-JSON bodies', () => {
function specFor(filePath: string, openapiPath: string, method: string, block: string) {
const content = src(method, block);
const fileContents = new Map([[filePath, content]]);
const annotationsByPath = { [openapiPath]: parseAnnotations(content) };
const pathParams = [...openapiPath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
const routes = [{ filePath, openapiPath, pathParams, tag: 'Test', methods: [method] as any }];
return buildSpec({
routes: routes as any,
fileContents,
annotationsByPath: annotationsByPath as any,
isPublicFn: () => true,
version: 'test'
}).spec;
}
test('body-raw -> application/x-tar content, required', () => {
const spec = specFor(
'/x/+server.ts',
'/api/images/load',
'POST',
' * @openapi\n * summary: Load\n * body-raw: application/x-tar The raw image tar'
);
const rb: any = spec.paths['/api/images/load'].post.requestBody;
expect(rb.required).toBe(true);
expect(rb.content['application/x-tar']).toBeDefined();
expect(rb.content['application/x-tar'].schema.type).toBe('string');
expect(rb.content['application/x-tar'].schema.format).toBe('binary');
});
test('body-multipart -> multipart/form-data with an array file field, required', () => {
const spec = specFor(
'/y/+server.ts',
'/api/containers/{id}/files/upload',
'POST',
' * @openapi\n * summary: Upload\n * body-multipart: files:binary[]! One or more files'
);
const rb: any = spec.paths['/api/containers/{id}/files/upload'].post.requestBody;
expect(rb.required).toBe(true);
const mp = rb.content['multipart/form-data'];
expect(mp).toBeDefined();
expect(mp.schema.type).toBe('object');
expect(mp.schema.properties.files.type).toBe('array');
expect(mp.schema.properties.files.items.format).toBe('binary');
expect(mp.schema.required).toEqual(['files']);
});
});