diff --git a/package.json b/package.json index 965c457..f42b980 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "fmt:check": "oxfmt --check", "gen:routes": "tsx scripts/gen-routes.ts", "gen:events": "tsx scripts/gen-events.ts", + "gen:shapes": "tsx scripts/gen-shapes.ts", "check:coverage": "tsx scripts/check-coverage.ts" }, "author": "WorkOS", diff --git a/scripts/gen-shapes-lib.spec.ts b/scripts/gen-shapes-lib.spec.ts new file mode 100644 index 0000000..5bd10b7 --- /dev/null +++ b/scripts/gen-shapes-lib.spec.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveSchema, + extractShape, + parseShapeCatalog, + generateShapesFile, + type ShapeMapEntry, +} from './gen-shapes-lib.js'; +import type { EventSchemaNode } from './gen-events-lib.js'; + +function spec(schemas: Record): EventSchemaNode { + return { components: { schemas } } as unknown as EventSchemaNode; +} +function schema(s: EventSchemaNode, name: string): EventSchemaNode { + return (s as { components: { schemas: Record } }).components.schemas[name]; +} + +describe('resolveSchema', () => { + it('follows a $ref to its target schema', () => { + const s = spec({ + Target: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + Ref: { $ref: '#/components/schemas/Target' }, + }); + const resolved = resolveSchema(schema(s, 'Ref'), s); + expect(Object.keys(resolved.properties ?? {})).toEqual(['id']); + }); + + it('merges allOf members — properties unioned, required concatenated', () => { + const s = spec({ + Base: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + Thing: { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + ], + } as unknown as EventSchemaNode, + }); + const resolved = resolveSchema(schema(s, 'Thing'), s); + expect(Object.keys(resolved.properties ?? {}).sort()).toEqual(['id', 'name']); + expect((resolved.required ?? []).sort()).toEqual(['id', 'name']); + }); + + it('does not loop on a self-referential $ref', () => { + const s = spec({ Cycle: { $ref: '#/components/schemas/Cycle' } }); + expect(() => resolveSchema(schema(s, 'Cycle'), s)).not.toThrow(); + }); + + it('throws when an allOf member resolves to a oneOf/anyOf instead of silently dropping its fields', () => { + const s = spec({ + Variant: { + oneOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'string' } } }, + ], + } as unknown as EventSchemaNode, + Thing: { + allOf: [{ type: 'object', properties: { id: { type: 'string' } } }, { $ref: '#/components/schemas/Variant' }], + } as unknown as EventSchemaNode, + }); + expect(() => resolveSchema(schema(s, 'Thing'), s)).toThrow(/oneOf\/anyOf/); + }); +}); + +describe('extractShape', () => { + const widgetSpec = spec({ + Widget: { + type: 'object', + properties: { object: { const: 'widget' }, id: { type: 'string' }, color: { type: 'string' } }, + required: ['id', 'object'], + }, + }); + + it('extracts sorted properties and required from the mapped schema', () => { + const shape = extractShape({ objectType: 'widget', schemaName: 'Widget' }, widgetSpec); + expect(shape.properties).toEqual(['color', 'id', 'object']); + expect(shape.required).toEqual(['id', 'object']); + expect(shape.schemaName).toBe('Widget'); + }); + + it('accepts an object discriminator expressed as a single-value enum', () => { + const s = spec({ + Widget: { + type: 'object', + properties: { object: { enum: ['widget'] }, id: { type: 'string' } }, + required: ['id'], + }, + }); + expect(() => extractShape({ objectType: 'widget', schemaName: 'Widget' }, s)).not.toThrow(); + }); + + it('throws when the mapped schema is missing', () => { + expect(() => extractShape({ objectType: 'widget', schemaName: 'Nope' }, widgetSpec)).toThrow(/not found/); + }); + + it('throws when the schema has no object discriminator (a request DTO)', () => { + const s = spec({ CreateWidgetDto: { type: 'object', properties: { color: { type: 'string' } } } }); + expect(() => extractShape({ objectType: 'widget', schemaName: 'CreateWidgetDto' }, s)).toThrow(/discriminator/); + }); + + it('throws when the object discriminator does not match the mapped type', () => { + expect(() => extractShape({ objectType: 'gadget', schemaName: 'Widget' }, widgetSpec)).toThrow(/expected "gadget"/); + }); +}); + +describe('parseShapeCatalog', () => { + it('extracts each map entry and sorts by object type', () => { + const s = spec({ + Beta: { type: 'object', properties: { object: { const: 'beta' }, id: { type: 'string' } }, required: ['id'] }, + Alpha: { type: 'object', properties: { object: { const: 'alpha' }, id: { type: 'string' } }, required: ['id'] }, + }); + const map: ShapeMapEntry[] = [ + { objectType: 'beta', schemaName: 'Beta' }, + { objectType: 'alpha', schemaName: 'Alpha' }, + ]; + expect(parseShapeCatalog(s, map).map((shape) => shape.objectType)).toEqual(['alpha', 'beta']); + }); +}); + +describe('generateShapesFile', () => { + it('emits a RESPONSE_SHAPE_REQUIREMENTS record keyed by object type', () => { + const out = generateShapesFile([ + { objectType: 'widget', schemaName: 'Widget', properties: ['id', 'object'], required: ['id'] }, + ]); + expect(out).toContain('export const RESPONSE_SHAPE_REQUIREMENTS'); + expect(out).toContain('widget: {'); + expect(out).toContain("schema: 'Widget'"); + expect(out).toContain('do not edit by hand'); + }); +}); diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts new file mode 100644 index 0000000..496cb17 --- /dev/null +++ b/scripts/gen-shapes-lib.ts @@ -0,0 +1,186 @@ +/** + * Core codegen logic for gen-shapes. Separated from the CLI entry point so the + * transformation functions can be unit-tested independently. + * + * Extracts per-resource response *shapes* (property + required field sets) from + * a WorkOS OpenAPI spec and generates src/workos/generated/response-shapes.ts. + * + * Unlike the event catalog — discovered structurally via properties.event.const + * — resource schemas are neither uniformly named nor uniformly shaped in the + * spec (e.g. `UserObject` is a partial SCIM-style user, while `UserlandUser` is + * the AuthKit User Management user). So the authoritative schema per emulator + * object type is curated in OBJECT_SCHEMA_MAP below: only the *selection* is + * hand-maintained — every field requirement is still extracted from the spec, + * and extraction fails loudly if a mapped schema's `object` discriminator does + * not match, so a spec rename can't silently point the test at the wrong shape. + */ +import type { EventSchemaNode } from './gen-events-lib.js'; + +export interface ShapeMapEntry { + /** The emulator's `object` discriminator, e.g. "user". */ + objectType: string; + /** The authoritative spec schema name in components.schemas, e.g. "UserlandUser". */ + schemaName: string; +} + +/** + * Which spec schema is authoritative for each emulator response object. + * + * Scoped to the pure-data resources whose emulator output should mirror the + * spec 1:1. Auth/flow payloads (authenticate, authorize) deliberately stay out + * — their shapes are covered by the event catalog's EVENT_DATA_REQUIREMENTS. + */ +export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [ + { objectType: 'user', schemaName: 'UserlandUser' }, + { objectType: 'organization', schemaName: 'Organization' }, + { objectType: 'connection', schemaName: 'Connection' }, + { objectType: 'directory', schemaName: 'Directory' }, + { objectType: 'directory_group', schemaName: 'DirectoryGroup' }, + { objectType: 'directory_user', schemaName: 'DirectoryUserWithGroups' }, + { objectType: 'role', schemaName: 'Role' }, + { objectType: 'permission', schemaName: 'AuthorizationPermission' }, +]; + +export interface ParsedShape { + objectType: string; + schemaName: string; + /** Every property the spec defines for this object, sorted. */ + properties: string[]; + /** Properties the spec marks required, sorted. */ + required: string[]; +} + +function getSchemas(spec: EventSchemaNode): Record { + const components = (spec as { components?: { schemas?: Record } }).components; + return components?.schemas ?? {}; +} + +/** + * Resolve a schema node to a plain object schema: follows $ref and merges allOf + * members (properties unioned, required concatenated). oneOf/anyOf cannot be + * resolved to a single shape and are left as-is at the top level (extractShape + * then fails loudly on the resulting empty property set). An allOf member that + * resolves to a oneOf/anyOf is rejected here rather than silently contributing + * no fields — otherwise the generated catalog would understate the spec shape. + * `seen` guards ref cycles. + */ +export function resolveSchema( + node: EventSchemaNode, + spec: EventSchemaNode, + seen: Set = new Set(), +): EventSchemaNode { + if (node.$ref) { + const match = node.$ref.match(/^#\/components\/schemas\/(.+)$/); + if (match && !seen.has(match[1])) { + seen.add(match[1]); + const target = getSchemas(spec)[match[1]]; + if (target) return resolveSchema(target, spec, seen); + } + return node; + } + + const allOf = node.allOf as EventSchemaNode[] | undefined; + if (allOf) { + const merged: EventSchemaNode = { type: 'object', properties: {}, required: [] }; + for (const sub of allOf) { + const resolved = resolveSchema(sub, spec, seen); + if (resolved.oneOf || resolved.anyOf) { + throw new Error( + 'gen-shapes: allOf member resolved to a oneOf/anyOf — cannot merge into a single object shape without dropping fields', + ); + } + Object.assign(merged.properties!, resolved.properties ?? {}); + if (resolved.required) merged.required!.push(...resolved.required); + } + // Properties/required declared alongside allOf also count. + Object.assign(merged.properties!, node.properties ?? {}); + if (node.required) merged.required!.push(...node.required); + return merged; + } + + return node; +} + +export function extractShape(entry: ShapeMapEntry, spec: EventSchemaNode): ParsedShape { + const raw = getSchemas(spec)[entry.schemaName]; + if (!raw) { + throw new Error( + `gen-shapes: schema "${entry.schemaName}" (mapped from object "${entry.objectType}") not found in components.schemas`, + ); + } + + const resolved = resolveSchema(raw, spec); + const properties = Object.keys(resolved.properties ?? {}); + if (properties.length === 0) { + throw new Error(`gen-shapes: schema "${entry.schemaName}" resolved to no properties — wrong schema name?`); + } + + // Guard the curation: the schema must declare an `object` discriminator that + // matches the emulator object type. Resource *response* schemas carry it + // (`object: { const: "user" }`); request DTOs do not — so this rejects a + // mismapping to e.g. `OrganizationDto`, and a spec rename that repoints a + // schema fails here instead of silently asserting against the wrong shape. + const objectField = resolved.properties?.object; + const objectConst = objectField?.const ?? (objectField?.enum?.length === 1 ? objectField.enum[0] : undefined); + if (objectConst === undefined) { + throw new Error( + `gen-shapes: schema "${entry.schemaName}" (object "${entry.objectType}") has no \`object\` discriminator — is it a response schema, not a request DTO?`, + ); + } + if (objectConst !== entry.objectType) { + throw new Error( + `gen-shapes: schema "${entry.schemaName}" has object const "${objectConst}", expected "${entry.objectType}"`, + ); + } + + return { + objectType: entry.objectType, + schemaName: entry.schemaName, + properties: [...properties].sort(), + required: [...(resolved.required ?? [])].sort(), + }; +} + +export function parseShapeCatalog( + spec: EventSchemaNode, + map: readonly ShapeMapEntry[] = OBJECT_SCHEMA_MAP, +): ParsedShape[] { + return map.map((entry) => extractShape(entry, spec)).sort((a, b) => a.objectType.localeCompare(b.objectType)); +} + +export function generateShapesFile(shapes: ParsedShape[]): string { + const lines: string[] = []; + lines.push('/**'); + lines.push(' * Generated by scripts/gen-shapes.ts — do not edit by hand.'); + lines.push(' * Source: the @workos/openapi-spec package. Regenerate with:'); + lines.push(' * npm run gen:shapes'); + lines.push(' *'); + lines.push(' * Per-resource response shape requirements, extracted from the spec schema'); + lines.push(' * curated for each object type in scripts/gen-shapes-lib.ts (OBJECT_SCHEMA_MAP).'); + lines.push(' * Consumed by src/workos/response-shapes.spec.ts to assert the hand-written'); + lines.push(' * format* helpers match the spec and never leak internal fields.'); + lines.push(' */'); + lines.push(''); + lines.push('export interface ResponseShapeRequirement {'); + lines.push(' /** The spec schema (components.schemas) this shape was extracted from. */'); + lines.push(' schema: string;'); + lines.push(' /** Every property the spec defines for this object. */'); + lines.push(' properties: readonly string[];'); + lines.push(' /** Properties the spec marks required. */'); + lines.push(' required: readonly string[];'); + lines.push('}'); + lines.push(''); + lines.push('export const RESPONSE_SHAPE_REQUIREMENTS: Record = {'); + for (const shape of shapes) { + const props = shape.properties.map((p) => `'${p}'`).join(', '); + const req = shape.required.map((p) => `'${p}'`).join(', '); + lines.push(` ${shape.objectType}: {`); + lines.push(` schema: '${shape.schemaName}',`); + lines.push(` properties: [${props}],`); + lines.push(` required: [${req}],`); + lines.push(' },'); + } + lines.push('};'); + lines.push(''); + return lines.join('\n'); +} diff --git a/scripts/gen-shapes.ts b/scripts/gen-shapes.ts new file mode 100644 index 0000000..393e97f --- /dev/null +++ b/scripts/gen-shapes.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx +/** + * Codegen script: reads the WorkOS OpenAPI spec and generates the response + * shape catalog (src/workos/generated/response-shapes.ts) — per-resource + * property and required-field sets, extracted from the schema curated for each + * object type in gen-shapes-lib.ts. + * + * By default the spec comes from the @workos/openapi-spec devDependency, so + * regenerating is just: + * npm run gen:shapes + * Update the dependency to pick up a newer spec: + * npm install -D @workos/openapi-spec@latest && npm run gen:shapes + * A local spec file can still be passed explicitly: + * npm run gen:shapes -- path/to/openapi.yaml [--out ] [--dry-run] + * + * The generated file is committed, so consumers of the package never need the + * spec. Re-running against a newer spec is the drift check. Running twice on + * the same spec produces identical output (idempotent). + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { format, type FormatConfig } from 'oxfmt'; + +import { type EventSchemaNode } from './gen-events-lib.js'; +import { parseShapeCatalog, generateShapesFile } from './gen-shapes-lib.js'; + +/** Load the project's oxfmt config so generated output matches `npm run fmt`. */ +function loadFormatConfig(): FormatConfig { + const configPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.oxfmtrc.json'); + return existsSync(configPath) ? (JSON.parse(readFileSync(configPath, 'utf-8')) as FormatConfig) : {}; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const flags = args.filter((a) => a.startsWith('--')); + const positional = args.filter((a) => !a.startsWith('--')); + + // Default to the published spec package; a positional path overrides it. + const specPath = positional[0] ?? createRequire(import.meta.url).resolve('@workos/openapi-spec/spec'); + + const dryRun = flags.includes('--dry-run'); + const outIdx = args.indexOf('--out'); + const outFile = outIdx !== -1 ? args[outIdx + 1] : 'src/workos/generated/response-shapes.ts'; + // A bare `--out` (no value, or followed by another flag) would otherwise write + // to a file literally named after the next flag — fail instead of silently + // leaving the committed generated file stale. + if (outIdx !== -1 && (!outFile || outFile.startsWith('--'))) { + console.error('Missing output file after --out'); + process.exit(1); + } + + const resolvedSpec = resolve(specPath); + if (!existsSync(resolvedSpec)) { + console.error(`Spec file not found: ${resolvedSpec}`); + process.exit(1); + } + + const raw = readFileSync(resolvedSpec, 'utf-8'); + const ext = extname(resolvedSpec).toLowerCase(); + const spec: EventSchemaNode = + ext === '.yaml' || ext === '.yml' ? (YAML.parse(raw) as EventSchemaNode) : (JSON.parse(raw) as EventSchemaNode); + + const shapes = parseShapeCatalog(spec); + const resolvedOut = resolve(outFile); + // The output path's `.ts` extension tells oxfmt to use the TypeScript parser. + const formatted = await format(resolvedOut, generateShapesFile(shapes), loadFormatConfig()); + if (formatted.errors.length > 0) { + console.error('oxfmt reported errors while formatting generated output:'); + for (const err of formatted.errors) console.error(` ${err.severity}: ${err.message}`); + process.exit(1); + } + const content = formatted.code; + + if (dryRun) { + console.log(content); + return; + } + + mkdirSync(dirname(resolvedOut), { recursive: true }); + writeFileSync(resolvedOut, content, 'utf-8'); + console.log(` wrote ${resolvedOut}`); + console.log(`\nShapes: ${shapes.length} resources`); +} + +await main(); diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts new file mode 100644 index 0000000..f5d69fc --- /dev/null +++ b/src/workos/generated/response-shapes.ts @@ -0,0 +1,210 @@ +/** + * Generated by scripts/gen-shapes.ts — do not edit by hand. + * Source: the @workos/openapi-spec package. Regenerate with: + * npm run gen:shapes + * + * Per-resource response shape requirements, extracted from the spec schema + * curated for each object type in scripts/gen-shapes-lib.ts (OBJECT_SCHEMA_MAP). + * Consumed by src/workos/response-shapes.spec.ts to assert the hand-written + * format* helpers match the spec and never leak internal fields. + */ + +export interface ResponseShapeRequirement { + /** The spec schema (components.schemas) this shape was extracted from. */ + schema: string; + /** Every property the spec defines for this object. */ + properties: readonly string[]; + /** Properties the spec marks required. */ + required: readonly string[]; +} + +export const RESPONSE_SHAPE_REQUIREMENTS: Record = { + connection: { + schema: 'Connection', + properties: [ + 'connection_type', + 'created_at', + 'domains', + 'id', + 'name', + 'object', + 'options', + 'organization_id', + 'state', + 'status', + 'updated_at', + ], + required: ['connection_type', 'created_at', 'domains', 'id', 'name', 'object', 'state', 'status', 'updated_at'], + }, + directory: { + schema: 'Directory', + properties: [ + 'created_at', + 'domain', + 'external_key', + 'id', + 'metadata', + 'name', + 'object', + 'organization_id', + 'state', + 'type', + 'updated_at', + ], + required: ['created_at', 'external_key', 'id', 'name', 'object', 'organization_id', 'state', 'type', 'updated_at'], + }, + directory_group: { + schema: 'DirectoryGroup', + properties: [ + 'created_at', + 'directory_id', + 'id', + 'idp_id', + 'name', + 'object', + 'organization_id', + 'raw_attributes', + 'updated_at', + ], + required: ['created_at', 'directory_id', 'id', 'idp_id', 'name', 'object', 'organization_id', 'updated_at'], + }, + directory_user: { + schema: 'DirectoryUserWithGroups', + properties: [ + 'created_at', + 'custom_attributes', + 'directory_id', + 'email', + 'emails', + 'first_name', + 'groups', + 'id', + 'idp_id', + 'job_title', + 'last_name', + 'name', + 'object', + 'organization_id', + 'raw_attributes', + 'role', + 'roles', + 'state', + 'updated_at', + 'username', + ], + required: [ + 'created_at', + 'custom_attributes', + 'directory_id', + 'email', + 'groups', + 'id', + 'idp_id', + 'object', + 'organization_id', + 'raw_attributes', + 'state', + 'updated_at', + ], + }, + organization: { + schema: 'Organization', + properties: [ + 'allow_profiles_outside_organization', + 'created_at', + 'domains', + 'external_id', + 'id', + 'metadata', + 'name', + 'object', + 'stripe_customer_id', + 'updated_at', + ], + required: ['created_at', 'domains', 'external_id', 'id', 'metadata', 'name', 'object', 'updated_at'], + }, + permission: { + schema: 'AuthorizationPermission', + properties: [ + 'created_at', + 'description', + 'id', + 'name', + 'object', + 'resource_type_slug', + 'slug', + 'system', + 'updated_at', + ], + required: [ + 'created_at', + 'description', + 'id', + 'name', + 'object', + 'resource_type_slug', + 'slug', + 'system', + 'updated_at', + ], + }, + role: { + schema: 'Role', + properties: [ + 'created_at', + 'description', + 'id', + 'name', + 'object', + 'permissions', + 'resource_type_slug', + 'slug', + 'type', + 'updated_at', + ], + required: [ + 'created_at', + 'description', + 'id', + 'name', + 'object', + 'permissions', + 'resource_type_slug', + 'slug', + 'type', + 'updated_at', + ], + }, + user: { + schema: 'UserlandUser', + properties: [ + 'created_at', + 'email', + 'email_verified', + 'external_id', + 'first_name', + 'id', + 'last_name', + 'last_sign_in_at', + 'locale', + 'metadata', + 'name', + 'object', + 'profile_picture_url', + 'updated_at', + ], + required: [ + 'created_at', + 'email', + 'email_verified', + 'external_id', + 'first_name', + 'id', + 'last_name', + 'last_sign_in_at', + 'object', + 'profile_picture_url', + 'updated_at', + ], + }, +}; diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts new file mode 100644 index 0000000..b5155db --- /dev/null +++ b/src/workos/response-shapes.spec.ts @@ -0,0 +1,248 @@ +/** + * Response shape conformance: asserts the hand-written `format*` helpers produce + * objects whose field sets match the OpenAPI spec — the contract customers see + * on the wire. The spec requirements come from src/workos/generated/ + * response-shapes.ts (regenerate with `npm run gen:shapes`); this test pins the + * emulator's actual output against them. + * + * Three assertions per resource: + * 1. forward — every spec-required field is present (modulo tracked gaps) + * 2. reverse — no field the spec doesn't define is returned (modulo tracked + * extras); this is the guard that catches leaked internals + * 3. leak — no known secret field is ever returned, unconditionally + * + * Divergences are not swept under the rug: they live in the ledgers below as + * exact sets. Closing a divergence (emit the field) forces deleting its ledger + * entry, and any *new* divergence fails the build — drift can't accrue silently. + */ +import { describe, it, expect } from 'vitest'; +import { Store } from '../core/index.js'; +import { getWorkOSStore } from './store.js'; +import { + formatUser, + formatOrganization, + formatConnection, + formatDirectory, + formatDirectoryGroup, + formatDirectoryUser, + formatRole, + formatPermission, +} from './helpers.js'; +import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; +import type { + WorkOSUser, + WorkOSOrganization, + WorkOSConnection, + WorkOSDirectory, + WorkOSDirectoryGroup, + WorkOSDirectoryUser, + WorkOSRole, + WorkOSPermission, +} from './entities.js'; + +const TS = '2026-01-01T00:00:00.000Z'; +const sorted = (xs: Iterable): string[] => [...xs].sort(); + +// Representative entities. Internal/secret fields (e.g. password_hash) are +// populated on purpose so the leak guard actually has something to catch. +const user: WorkOSUser = { + id: 'user_01', + object: 'user', + email: 'alice@example.com', + first_name: 'Alice', + last_name: 'Smith', + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: 'sha256-deadbeef', + impersonator: null, + created_at: TS, + updated_at: TS, +}; + +const organization: WorkOSOrganization = { + id: 'org_01', + object: 'organization', + name: 'Acme', + external_id: null, + metadata: {}, + stripe_customer_id: null, + created_at: TS, + updated_at: TS, +}; + +const connection: WorkOSConnection = { + id: 'conn_01', + object: 'connection', + organization_id: 'org_01', + connection_type: 'GenericSAML', + name: 'Acme SSO', + state: 'active', + domains: [], + created_at: TS, + updated_at: TS, +}; + +const directory: WorkOSDirectory = { + id: 'directory_01', + object: 'directory', + name: 'Acme Directory', + organization_id: 'org_01', + domain: 'acme.com', + type: 'okta scim v2.0', + state: 'linked', + external_key: 'ext_abc', + created_at: TS, + updated_at: TS, +}; + +const directoryGroup: WorkOSDirectoryGroup = { + id: 'directory_grp_01', + object: 'directory_group', + directory_id: 'directory_01', + organization_id: 'org_01', + idp_id: 'idp_grp_1', + name: 'Admins', + raw_attributes: {}, + created_at: TS, + updated_at: TS, +}; + +const directoryUser: WorkOSDirectoryUser = { + id: 'directory_user_01', + object: 'directory_user', + directory_id: 'directory_01', + organization_id: 'org_01', + idp_id: 'idp_usr_1', + first_name: 'Bob', + last_name: 'Jones', + email: 'bob@acme.com', + username: 'bjones', + state: 'active', + role: { slug: 'member' }, + custom_attributes: {}, + raw_attributes: {}, + groups: [], + created_at: TS, + updated_at: TS, +}; + +const role: WorkOSRole = { + id: 'role_01', + object: 'role', + slug: 'admin', + name: 'Admin', + description: null, + type: 'EnvironmentRole', + organization_id: null, + is_default_role: false, + priority: 0, + created_at: TS, + updated_at: TS, +}; + +const permission: WorkOSPermission = { + id: 'perm_01', + object: 'permission', + slug: 'posts:read', + name: 'Read Posts', + description: null, + created_at: TS, + updated_at: TS, +}; + +const store = new Store(); +const ws = getWorkOSStore(store); + +const CASES: ReadonlyArray<{ objectType: string; output: Record }> = [ + { objectType: 'user', output: formatUser(user) }, + { objectType: 'organization', output: formatOrganization(organization, ws, { domains: [] }) }, + { objectType: 'connection', output: formatConnection(connection) }, + { objectType: 'directory', output: formatDirectory(directory) }, + { objectType: 'directory_group', output: formatDirectoryGroup(directoryGroup) }, + { objectType: 'directory_user', output: formatDirectoryUser(directoryUser) }, + { objectType: 'role', output: formatRole(role) }, + { objectType: 'permission', output: formatPermission(permission) }, +]; + +/** + * Spec-required fields the emulator does not yet return. Each is a real, tracked + * gap between the emulator's data model and the current spec — not noise. + */ +const KNOWN_MISSING_REQUIRED: Record = { + // Spec models a connection `status` distinct from `state`; the emulator's + // WorkOSConnection carries only `state`. + connection: ['status'], + // The emulator's Role predates the spec's authorization Role: it has no + // `permissions` array or `resource_type_slug`. + role: ['permissions', 'resource_type_slug'], + // The emulator's Permission lacks the spec's `resource_type_slug` and `system`. + permission: ['resource_type_slug', 'system'], +}; + +/** + * Fields the emulator returns that the spec schema does not define. Legitimate + * model differences only — never a secret (asserted by the meta-test below). + */ +const KNOWN_EXTRA_FIELDS: Record = { + // The emulator's legacy Role is environment/organization-scoped with default + // and priority semantics the spec's authorization Role does not model. + role: ['is_default_role', 'organization_id', 'priority'], +}; + +/** + * Internal fields that must never appear in a response for the catalog + * resources (CASES) — these mirror the `INTERNAL_FIELDS` the format* helpers + * strip, so this guard proves the stripping actually happens. + * + * Scope note: this set deliberately omits auth-code/token field names + * (`code`, `token`, ...). Those belong to flow resources — email verification, + * magic auth, password reset, client secrets, API keys — whose formatters + * intentionally surface the value so a test harness can complete the flow + * without an out-of-band channel. The real API hides them; an emulator must + * not, which is exactly why those formatters are not in this catalog. Listing + * those names here would imply a coverage this loop does not provide. + */ +const SECRET_FIELDS = new Set(['password_hash', 'code_challenge', 'code_challenge_method']); + +describe('response shape conformance (format* helpers vs OpenAPI spec)', () => { + it('covers exactly the resources in the generated requirements catalog', () => { + expect(sorted(CASES.map((c) => c.objectType))).toEqual(sorted(Object.keys(RESPONSE_SHAPE_REQUIREMENTS))); + }); + + it('never lets a known-extra ledger entry excuse a secret field', () => { + for (const [objectType, fields] of Object.entries(KNOWN_EXTRA_FIELDS)) { + for (const field of fields) { + expect(SECRET_FIELDS.has(field), `${objectType}.${field} is a secret and cannot be ledgered as extra`).toBe( + false, + ); + } + } + }); + + for (const { objectType, output } of CASES) { + const requirement = RESPONSE_SHAPE_REQUIREMENTS[objectType]; + const outputKeys = Object.keys(output); + + describe(objectType, () => { + it('returns every spec-required field (modulo tracked gaps)', () => { + const missing = sorted(requirement.required.filter((field) => !outputKeys.includes(field))); + expect(missing).toEqual(sorted(KNOWN_MISSING_REQUIRED[objectType] ?? [])); + }); + + it('returns no field absent from the spec schema (modulo tracked extras)', () => { + const props = new Set(requirement.properties); + const extra = sorted(outputKeys.filter((key) => !props.has(key))); + expect(extra).toEqual(sorted(KNOWN_EXTRA_FIELDS[objectType] ?? [])); + }); + + it('never leaks an internal/secret field', () => { + const leaked = sorted(outputKeys.filter((key) => SECRET_FIELDS.has(key))); + expect(leaked).toEqual([]); + }); + }); + } +});