|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | +import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-sweep' |
| 6 | + |
| 7 | +/** |
| 8 | + * Every v2 schema description is user-facing prose on two surfaces at once: the |
| 9 | + * published API reference and `sim <command> --help`, which is generated from |
| 10 | + * these exact strings. A description spelling an HTTP method and path tells a |
| 11 | + * CLI caller to do something the CLI cannot do, so descriptions name the |
| 12 | + * operation and its object rather than the transport. |
| 13 | + * |
| 14 | + * This is a sweep rather than a handful of per-file assertions because the |
| 15 | + * strings that regressed last time sat a few lines from ones already fixed by |
| 16 | + * hand. Anything deliberately left alone goes in ALLOWED below with its reason, |
| 17 | + * and the sweep fails when an allowlisted description no longer appears, so the |
| 18 | + * list cannot rot. |
| 19 | + * |
| 20 | + * Allowlisting is keyed by the description text, not by schema path: these |
| 21 | + * schemas are shared between contracts, so one sentence surfaces under many |
| 22 | + * paths and fixing it must clear every one of them at once. |
| 23 | + */ |
| 24 | + |
| 25 | +const ENDPOINT_SPELLING = /\b(GET|POST|PATCH|PUT|DELETE)\s+\// |
| 26 | + |
| 27 | +/** Depth cap so a self-referential `lazy` schema cannot spin the walk. */ |
| 28 | +const MAX_DEPTH = 12 |
| 29 | + |
| 30 | +/** |
| 31 | + * Descriptions still naming a transport. Every entry is a contract file owned by |
| 32 | + * another change in flight — none is a judgment that the spelling is correct. |
| 33 | + */ |
| 34 | +const ALLOWED = new Map<string, string>([ |
| 35 | + [ |
| 36 | + 'Tag definition identifier. Published because `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by it; without it those operations are unreachable from a list read.', |
| 37 | + 'knowledge tag contracts owned elsewhere', |
| 38 | + ], |
| 39 | + [ |
| 40 | + 'Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.', |
| 41 | + 'knowledge tag contracts owned elsewhere', |
| 42 | + ], |
| 43 | + [ |
| 44 | + 'Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.', |
| 45 | + 'knowledge.ts owned elsewhere', |
| 46 | + ], |
| 47 | + [ |
| 48 | + 'ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.', |
| 49 | + 'knowledge.ts owned elsewhere', |
| 50 | + ], |
| 51 | + [ |
| 52 | + 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', |
| 53 | + 'knowledge.ts owned elsewhere', |
| 54 | + ], |
| 55 | + [ |
| 56 | + 'Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.', |
| 57 | + 'knowledge.ts owned elsewhere', |
| 58 | + ], |
| 59 | + [ |
| 60 | + 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction.', |
| 61 | + 'workflows.ts owned elsewhere', |
| 62 | + ], |
| 63 | + [ |
| 64 | + 'The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back.', |
| 65 | + 'workflows.ts owned elsewhere', |
| 66 | + ], |
| 67 | + [ |
| 68 | + 'Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`.', |
| 69 | + 'workflows.ts owned elsewhere', |
| 70 | + ], |
| 71 | + [ |
| 72 | + 'Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.', |
| 73 | + 'workflows.ts owned elsewhere', |
| 74 | + ], |
| 75 | + ['Custom tool id returned by `GET /api/v2/custom-tools`.', 'workflows.ts owned elsewhere'], |
| 76 | + [ |
| 77 | + 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`.', |
| 78 | + 'workflows.ts owned elsewhere', |
| 79 | + ], |
| 80 | +]) |
| 81 | + |
| 82 | +interface Described { |
| 83 | + /** `file.ts#exportName.field`, so a failure names the symbol to edit. */ |
| 84 | + key: string |
| 85 | + description: string |
| 86 | +} |
| 87 | + |
| 88 | +function describedOf(node: unknown): string | undefined { |
| 89 | + const described = node as { description?: unknown; meta?: () => { description?: unknown } } |
| 90 | + if (typeof described?.description === 'string') return described.description |
| 91 | + const meta = typeof described?.meta === 'function' ? described.meta() : undefined |
| 92 | + return typeof meta?.description === 'string' ? meta.description : undefined |
| 93 | +} |
| 94 | + |
| 95 | +function collect(node: unknown, key: string, seen: Set<unknown>, out: Described[], depth: number) { |
| 96 | + if (!node || typeof node !== 'object' || depth <= 0 || seen.has(node)) return |
| 97 | + seen.add(node) |
| 98 | + const def = (node as { def?: Record<string, unknown> }).def |
| 99 | + if (!def) return |
| 100 | + |
| 101 | + const description = describedOf(node) |
| 102 | + if (description) out.push({ key, description }) |
| 103 | + |
| 104 | + for (const wrapper of ['innerType', 'in', 'out', 'schema', 'element', 'valueType', 'keyType']) { |
| 105 | + if (def[wrapper]) collect(def[wrapper], key, seen, out, depth - 1) |
| 106 | + } |
| 107 | + for (const option of (def.options as unknown[] | undefined) ?? []) { |
| 108 | + collect(option, key, seen, out, depth - 1) |
| 109 | + } |
| 110 | + for (const [field, child] of Object.entries( |
| 111 | + (def.shape as Record<string, unknown> | undefined) ?? {} |
| 112 | + )) { |
| 113 | + collect(child, `${key}.${field}`, seen, out, depth - 1) |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +/** Every description reachable from an exported schema or route contract. */ |
| 118 | +async function sweepDescriptions(): Promise<Described[]> { |
| 119 | + const out: Described[] = [] |
| 120 | + for (const file of listContractFiles().filter((path) => path.includes('/contracts/v2/'))) { |
| 121 | + const name = file.split('/contracts/v2/')[1] |
| 122 | + const module = (await import(file)) as Record<string, unknown> |
| 123 | + for (const [exported, value] of Object.entries(module)) { |
| 124 | + if (!value || typeof value !== 'object') continue |
| 125 | + /** |
| 126 | + * A fresh visited set per export: schemas are shared between contracts, and |
| 127 | + * deduplicating across them would report a shared field under whichever |
| 128 | + * export reached it first and hide the rest. |
| 129 | + */ |
| 130 | + const seen = new Set<unknown>() |
| 131 | + const key = `${name}#${exported}` |
| 132 | + if ('def' in value) { |
| 133 | + collect(value, key, seen, out, MAX_DEPTH) |
| 134 | + continue |
| 135 | + } |
| 136 | + const contract = value as { |
| 137 | + params?: unknown |
| 138 | + query?: unknown |
| 139 | + body?: unknown |
| 140 | + headers?: unknown |
| 141 | + response?: { schema?: unknown } |
| 142 | + } |
| 143 | + for (const slot of ['params', 'query', 'body', 'headers'] as const) { |
| 144 | + if (contract[slot]) collect(contract[slot], `${key}.${slot}`, seen, out, MAX_DEPTH) |
| 145 | + } |
| 146 | + if (contract.response?.schema) { |
| 147 | + collect(contract.response.schema, `${key}.response`, seen, out, MAX_DEPTH) |
| 148 | + } |
| 149 | + } |
| 150 | + } |
| 151 | + return out |
| 152 | +} |
| 153 | + |
| 154 | +function offendingDescriptions(described: Described[]): Map<string, string[]> { |
| 155 | + const byDescription = new Map<string, string[]>() |
| 156 | + for (const { key, description } of described) { |
| 157 | + if (!ENDPOINT_SPELLING.test(description)) continue |
| 158 | + const keys = byDescription.get(description) |
| 159 | + if (keys) keys.push(key) |
| 160 | + else byDescription.set(description, [key]) |
| 161 | + } |
| 162 | + return byDescription |
| 163 | +} |
| 164 | + |
| 165 | +describe('v2 schema descriptions', () => { |
| 166 | + it('name the operation rather than an HTTP method and path', async () => { |
| 167 | + const described = await sweepDescriptions() |
| 168 | + expect(described.length).toBeGreaterThan(1000) |
| 169 | + |
| 170 | + const unexpected = [...offendingDescriptions(described)] |
| 171 | + .filter(([description]) => !ALLOWED.has(description)) |
| 172 | + .map(([description, keys]) => `${keys[0]} :: ${description}`) |
| 173 | + |
| 174 | + expect(unexpected).toEqual([]) |
| 175 | + }) |
| 176 | + |
| 177 | + it('keeps the allowlist honest', async () => { |
| 178 | + const offending = offendingDescriptions(await sweepDescriptions()) |
| 179 | + const stale = [...ALLOWED.keys()].filter((description) => !offending.has(description)) |
| 180 | + |
| 181 | + expect(stale).toEqual([]) |
| 182 | + }) |
| 183 | +}) |
0 commit comments