|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `DatasourceSchema.name` refuses the route-shadowed names (#29308). |
| 5 | + * |
| 6 | + * ## What was wrong |
| 7 | + * |
| 8 | + * The datasource admin API mounts the driver catalog at |
| 9 | + * `GET /api/v1/datasources/drivers`, ahead of `GET /api/v1/datasources/:name`, |
| 10 | + * so a datasource named `drivers` can never be read by name. The admin create |
| 11 | + * door refused it, but `DatasourceSchema.name` accepted it, so the same |
| 12 | + * unreachable name was minted from code (`defineStack`) or through the |
| 13 | + * metadata save door (`PUT /api/v1/meta/datasource/drivers`). |
| 14 | + * |
| 15 | + * ## What is pinned |
| 16 | + * |
| 17 | + * - the reserved set is ONE exported constant, frozen, whose entries are |
| 18 | + * identifiers the `name` pattern accepts (so the refusal pattern built from |
| 19 | + * them needs no escaping); |
| 20 | + * - `DatasourceSchema` refuses every entry: one issue at `name`, an |
| 21 | + * `invalid_format` whose message names the name and the shadowing route, |
| 22 | + * and carries no tracker number; |
| 23 | + * - `defineStack` (the code door) refuses it with the ADR-0112 envelope at |
| 24 | + * `datasources.0.name`; |
| 25 | + * - the published JSON Schema carries the refusal (a second `pattern`), so a |
| 26 | + * JSON-Schema consumer refuses the name too; |
| 27 | + * - CONTROLS: near names (`driver`, `drivers_archive`, `my_drivers`), the |
| 28 | + * host's `default` row and `test` (a literal that shadows no by-name read) |
| 29 | + * still parse. |
| 30 | + * |
| 31 | + * The admin door's half (the registrar mounts the literal from this list, and |
| 32 | + * the create door refuses it) is pinned in |
| 33 | + * `packages/services/service-datasource/src/__tests__/datasource-route-reserved-names.test.ts`. |
| 34 | + */ |
| 35 | +import { describe, it, expect } from 'vitest'; |
| 36 | +import { z } from 'zod'; |
| 37 | +import { DatasourceSchema, ROUTE_SHADOWED_DATASOURCE_NAMES } from './datasource.zod'; |
| 38 | +import { defineStack } from '../stack.zod'; |
| 39 | + |
| 40 | +const sqlite = (name: string) => ({ name, driver: 'sqlite', config: { filename: `${name}.db` } }); |
| 41 | +const manifest = { id: 'com.example.fx', name: 'fx', version: '1.0.0', type: 'app' as const }; |
| 42 | + |
| 43 | +type Envelope = Error & { |
| 44 | + code?: string; |
| 45 | + status?: number; |
| 46 | + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; message?: string }>; |
| 47 | +}; |
| 48 | + |
| 49 | +function refusal(fn: () => unknown): Envelope | null { |
| 50 | + try { |
| 51 | + fn(); |
| 52 | + return null; |
| 53 | + } catch (e) { |
| 54 | + return e as Envelope; |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +describe('ROUTE_SHADOWED_DATASOURCE_NAMES', () => { |
| 59 | + it('is the frozen list the admin API shadows today: `drivers`', () => { |
| 60 | + expect([...ROUTE_SHADOWED_DATASOURCE_NAMES]).toEqual(['drivers']); |
| 61 | + expect(Object.isFrozen(ROUTE_SHADOWED_DATASOURCE_NAMES)).toBe(true); |
| 62 | + }); |
| 63 | + |
| 64 | + it('every entry is an identifier the `name` pattern accepts, so the refusal pattern needs no escaping', () => { |
| 65 | + for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) { |
| 66 | + expect(name, name).toMatch(/^[a-z_][a-z0-9_]*$/); |
| 67 | + } |
| 68 | + }); |
| 69 | +}); |
| 70 | + |
| 71 | +describe('DatasourceSchema.name refuses a route-shadowed name', () => { |
| 72 | + for (const name of ROUTE_SHADOWED_DATASOURCE_NAMES) { |
| 73 | + it(`refuses '${name}' with one issue at \`name\` naming the name and the route`, () => { |
| 74 | + const result = DatasourceSchema.safeParse(sqlite(name)); |
| 75 | + expect(result.success).toBe(false); |
| 76 | + const issues = result.error!.issues; |
| 77 | + expect(issues).toHaveLength(1); |
| 78 | + expect(issues[0]!.code).toBe('invalid_format'); |
| 79 | + expect(issues[0]!.path).toEqual(['name']); |
| 80 | + const message = issues[0]!.message; |
| 81 | + expect(message.startsWith(`Datasource name '${name}' is reserved: `), message).toBe(true); |
| 82 | + expect(message).toContain(`/api/v1/datasources/${name}`); |
| 83 | + expect(message).toContain('could never be read by name'); |
| 84 | + expect(message).not.toMatch(/#\d{3,}/); |
| 85 | + }); |
| 86 | + |
| 87 | + it(`the refusal for '${name}' names only that name: built from the input, never from the whole list`, () => { |
| 88 | + const message = DatasourceSchema.safeParse(sqlite(name)).error!.issues[0]!.message; |
| 89 | + // Every quoted name and every literal route the sentence carries is this one. |
| 90 | + expect([...message.matchAll(/'([a-z0-9_]+)'/g)].map((m) => m[1])).toEqual([name]); |
| 91 | + expect([...message.matchAll(/\/api\/v1\/datasources\/([a-z0-9_]+)/g)].map((m) => m[1])).toEqual([name]); |
| 92 | + }); |
| 93 | + } |
| 94 | + |
| 95 | + it('the code door: defineStack refuses it with STACK_SCHEMA_INVALID / 422 at datasources.0.name', () => { |
| 96 | + const refused = refusal(() => defineStack({ manifest, datasources: [sqlite('drivers')] } as never)); |
| 97 | + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); |
| 98 | + expect(refused?.status).toBe(422); |
| 99 | + const hits = (refused?.issues ?? []).filter((i) => (i.message ?? '').startsWith("Datasource name 'drivers' is reserved")); |
| 100 | + expect(hits).toHaveLength(1); |
| 101 | + expect(hits[0]!.path).toEqual(['datasources', 0, 'name']); |
| 102 | + }); |
| 103 | + |
| 104 | + it('the published JSON Schema carries the refusal: some `name` pattern rejects each reserved name', () => { |
| 105 | + const json = z.toJSONSchema(DatasourceSchema, { io: 'input', unrepresentable: 'any' }) as { |
| 106 | + properties: Record<string, { pattern?: string; allOf?: Array<{ pattern?: string }> }>; |
| 107 | + }; |
| 108 | + const name = json.properties.name!; |
| 109 | + const patterns = [name.pattern, ...(name.allOf ?? []).map((s) => s.pattern)] |
| 110 | + .filter((p): p is string => typeof p === 'string') |
| 111 | + .map((p) => new RegExp(p)); |
| 112 | + expect(patterns.length).toBeGreaterThanOrEqual(2); |
| 113 | + for (const reserved of ROUTE_SHADOWED_DATASOURCE_NAMES) { |
| 114 | + expect(patterns.every((p) => p.test(reserved)), reserved).toBe(false); |
| 115 | + } |
| 116 | + for (const legal of ['reporting', 'drivers_archive', 'driver']) { |
| 117 | + expect(patterns.every((p) => p.test(legal)), legal).toBe(true); |
| 118 | + } |
| 119 | + }); |
| 120 | + |
| 121 | + it('CONTROL: near names, `test` and the host `default` row still parse', () => { |
| 122 | + for (const name of ['driver', 'drivers_archive', 'my_drivers', '_drivers', 'test', 'default', 'reporting']) { |
| 123 | + const result = DatasourceSchema.safeParse(sqlite(name)); |
| 124 | + expect(result.success, `${name}: ${JSON.stringify(result.error?.issues)}`).toBe(true); |
| 125 | + } |
| 126 | + }); |
| 127 | + |
| 128 | + it('CONTROL: an off-pattern name still meets the identifier pattern, not this refusal', () => { |
| 129 | + const result = DatasourceSchema.safeParse(sqlite('Drivers')); |
| 130 | + expect(result.success).toBe(false); |
| 131 | + expect(result.error!.issues.map((i) => i.message).some((m) => m.includes('is reserved'))).toBe(false); |
| 132 | + }); |
| 133 | +}); |
0 commit comments