|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#27762] A datasource saved through the metadata door may name, in |
| 4 | +// `external.credentialsRef`, only the reference its own stored row already |
| 5 | +// holds — the own-reference rule #27582 set (a non-system write may only seal |
| 6 | +// a new value or keep its own record's stored reference), pinned at the REST |
| 7 | +// door `PUT /api/v1/meta/datasource/:name` of a booted stack, as a signed-in |
| 8 | +// non-system caller. `packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts` |
| 9 | +// drives every face of the one save this door calls; this file holds the rule |
| 10 | +// through the door a client writes through: |
| 11 | +// |
| 12 | +// - a create naming a reference is refused (`VALIDATION_ERROR` / 400) before |
| 13 | +// anything is written, with one answer whatever the reference names, and |
| 14 | +// the reference is never echoed; |
| 15 | +// - an edit naming a reference other than its own is refused, and the stored |
| 16 | +// reference stays; |
| 17 | +// - the GET → edit → PUT round trip keeps the datasource's own reference. |
| 18 | +// |
| 19 | +// The stored rows are seeded through the protocol's server-stated rewrite (the |
| 20 | +// system pass-through the rule keeps): this harness composes no secret binder, |
| 21 | +// so no door here mints a reference. |
| 22 | + |
| 23 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 24 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 25 | +import { defineStack } from '@objectstack/spec'; |
| 26 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 27 | + |
| 28 | +const fixture = defineStack({ |
| 29 | + manifest: { |
| 30 | + id: 'com.dogfood.datasource-credentials-ref', namespace: 'dcr', version: '0.0.0', type: 'app', |
| 31 | + name: 'Datasource credentials reference fixture', description: 'An app with one object, for its metadata door.', |
| 32 | + }, |
| 33 | + objects: [ |
| 34 | + ObjectSchema.create({ |
| 35 | + name: 'dcr_note', label: 'Note', pluralLabel: 'Notes', |
| 36 | + fields: { name: Field.text({ label: 'Name', required: true }) }, |
| 37 | + }), |
| 38 | + ], |
| 39 | +} as never); |
| 40 | + |
| 41 | +/** Holds `OWN_REF`. */ |
| 42 | +const OWN = 'dcr_own_27762'; |
| 43 | +/** Another datasource, holding `OTHER_REF`. */ |
| 44 | +const OTHER = 'dcr_other_27762'; |
| 45 | +/** No row until a save makes one. */ |
| 46 | +const FRESH = 'dcr_fresh_27762'; |
| 47 | +const OWN_REF = 'sys_secret:dcr_own_27762'; |
| 48 | +const OTHER_REF = 'sys_secret:dcr_other_27762'; |
| 49 | +const UNHELD_REF = 'sys_secret:dcr_held_by_no_row_27762'; |
| 50 | + |
| 51 | +const SYS = { isSystem: true }; |
| 52 | + |
| 53 | +/** A schema-valid datasource body, so every refusal below is the rule's and never a 422. */ |
| 54 | +const body = (name: string, label: string, credentialsRef?: string) => ({ |
| 55 | + name, |
| 56 | + label, |
| 57 | + driver: 'postgres', |
| 58 | + config: { host: 'db.internal', database: name, username: 'app' }, |
| 59 | + ...(credentialsRef === undefined ? {} : { external: { credentialsRef } }), |
| 60 | +}); |
| 61 | + |
| 62 | +type Answer = { status: number; json: any; text: string }; |
| 63 | + |
| 64 | +describe('[#27762] datasource `external.credentialsRef`: a non-system save through the metadata door may only keep its own stored reference', () => { |
| 65 | + let stack: VerifyStack; |
| 66 | + let ql: any; |
| 67 | + let token = ''; |
| 68 | + |
| 69 | + const call = async (method: string, path: string, payload?: unknown): Promise<Answer> => { |
| 70 | + const res = await stack.apiAs(token, method, path, payload); |
| 71 | + const text = await res.text(); |
| 72 | + let json: any = {}; |
| 73 | + try { json = JSON.parse(text); } catch { /* not JSON */ } |
| 74 | + return { status: res.status, json, text }; |
| 75 | + }; |
| 76 | + /** `{ status, code, message }` from the door's ADR-0112 envelope. */ |
| 77 | + const refusalOf = (a: Answer) => { |
| 78 | + const error = a.json?.error; |
| 79 | + return typeof error === 'object' && error !== null |
| 80 | + ? { status: a.status, code: error.code, message: String(error.message ?? '') } |
| 81 | + : { status: a.status, code: a.json?.code, message: String(error ?? '') }; |
| 82 | + }; |
| 83 | + const storedRows = async (name: string): Promise<Array<Record<string, unknown>>> => |
| 84 | + ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS }); |
| 85 | + const storedRef = async (name: string) => { |
| 86 | + const [row] = await storedRows(name); |
| 87 | + const raw = row?.metadata; |
| 88 | + return (typeof raw === 'string' ? JSON.parse(raw) : (raw as any))?.external?.credentialsRef; |
| 89 | + }; |
| 90 | + const expectRuleRefusal = (a: Answer) => { |
| 91 | + expect({ status: a.status, code: refusalOf(a).code }, a.text).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); |
| 92 | + for (const ref of [OWN_REF, OTHER_REF, UNHELD_REF]) expect(a.text).not.toContain(ref); |
| 93 | + }; |
| 94 | + |
| 95 | + beforeAll(async () => { |
| 96 | + stack = await bootStack(fixture); |
| 97 | + token = await stack.signIn(); |
| 98 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 99 | + const protocol: any = await stack.kernel.getServiceAsync('protocol'); |
| 100 | + for (const [name, ref] of [[OWN, OWN_REF], [OTHER, OTHER_REF]] as const) { |
| 101 | + await protocol.saveMetaItem({ type: 'datasource', name, item: body(name, name, ref), packageId: null, source: 'migrate-stored' }); |
| 102 | + expect(await storedRef(name)).toBe(ref); |
| 103 | + } |
| 104 | + }, 180_000); |
| 105 | + |
| 106 | + afterAll(async () => { |
| 107 | + await stack?.stop(); |
| 108 | + }); |
| 109 | + |
| 110 | + it('a create naming a reference is refused before anything is written, with one answer whatever it names', async () => { |
| 111 | + const other = await call('PUT', `/meta/datasource/${FRESH}`, body(FRESH, 'Fresh', OTHER_REF)); |
| 112 | + const unheld = await call('PUT', `/meta/datasource/${FRESH}`, body(FRESH, 'Fresh', UNHELD_REF)); |
| 113 | + expectRuleRefusal(other); |
| 114 | + expectRuleRefusal(unheld); |
| 115 | + expect(refusalOf(unheld)).toEqual(refusalOf(other)); |
| 116 | + expect(await storedRows(FRESH)).toEqual([]); |
| 117 | + }); |
| 118 | + |
| 119 | + it('an edit naming a reference other than its own is refused, and the stored reference stays', async () => { |
| 120 | + const refused = await call('PUT', `/meta/datasource/${OWN}`, body(OWN, 'Own (edited)', OTHER_REF)); |
| 121 | + expectRuleRefusal(refused); |
| 122 | + expect(await storedRef(OWN)).toBe(OWN_REF); |
| 123 | + const [row] = await storedRows(OWN); |
| 124 | + expect(JSON.parse(String(row.metadata)).label).toBe(OWN); |
| 125 | + }); |
| 126 | + |
| 127 | + it('the GET → edit → PUT round trip keeps the datasource\'s own reference', async () => { |
| 128 | + const read = await call('GET', `/meta/datasource/${OWN}`); |
| 129 | + expect(read.status, read.text).toBe(200); |
| 130 | + const served = Object.fromEntries(Object.entries(read.json.item ?? {}).filter(([k]) => !k.startsWith('_'))) as any; |
| 131 | + expect(served.external?.credentialsRef).toBe(OWN_REF); |
| 132 | + const saved = await call('PUT', `/meta/datasource/${OWN}`, { ...served, label: 'Own (round trip)' }); |
| 133 | + expect(saved.status, saved.text).toBe(200); |
| 134 | + expect(await storedRef(OWN)).toBe(OWN_REF); |
| 135 | + const [row] = await storedRows(OWN); |
| 136 | + expect(JSON.parse(String(row.metadata)).label).toBe('Own (round trip)'); |
| 137 | + }); |
| 138 | +}); |
0 commit comments