|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [ruling B+ on #24966, #24994] Every meta read door withholds a webhook's |
| 4 | +// declared credentials — `secret` (a credential value) and `headers` (a |
| 5 | +// credential map, as a whole) — in BOTH compositions: without |
| 6 | +// `@objectstack/plugin-webhooks`, and with it mounted. |
| 7 | +// |
| 8 | +// The rule under test: `WebhookSchema` marks the two fields with |
| 9 | +// `.meta({ credential: … })`, and `getMetadataTypeRedactor('webhook')` |
| 10 | +// resolves a redactor DERIVED from those markers (`@objectstack/spec/kernel`), |
| 11 | +// present from module load whatever the composition mounts. A webhook's items |
| 12 | +// reach the meta read doors identically with and without the plugin (declared |
| 13 | +// ones through manifest decomposition, saved ones through the meta save door), |
| 14 | +// so a plugin registration would protect only one of the two compositions; |
| 15 | +// this file boots both. |
| 16 | +// |
| 17 | +// Doors pinned, per composition: the meta list (both spellings), the item read, |
| 18 | +// the layered view, the published read, the metadata protocol's list and item |
| 19 | +// reads, and the stored-row data door with its history twin. Each door read |
| 20 | +// carries a positive control (the response names the webhook), so a refusal or |
| 21 | +// an empty answer cannot pass for a withheld credential. |
| 22 | +// |
| 23 | +// Also pinned: the stored record is never mutated (redaction is a serving |
| 24 | +// act); a save of the served form keeps the stored values (the metadata |
| 25 | +// protocol's carry-forward); and, with the plugin mounted, the dispatcher's |
| 26 | +// declared-webhook source still carries both credentials, so delivery signing |
| 27 | +// is unaffected. |
| 28 | +// |
| 29 | +// Disclosure: every credential below is an obviously-fake placeholder, and |
| 30 | +// every assertion is absence by rule, never a served value. |
| 31 | + |
| 32 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 33 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 34 | +import { MessagingServicePlugin } from '@objectstack/service-messaging'; |
| 35 | +import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; |
| 36 | +import { defineStack } from '@objectstack/spec'; |
| 37 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 38 | +import { defineWebhook } from '@objectstack/spec/automation'; |
| 39 | + |
| 40 | +const SYSTEM = { isSystem: true, positions: [], permissions: [] }; |
| 41 | + |
| 42 | +/** Obviously-fake placeholders: absence of these strings is the whole assertion. */ |
| 43 | +const DECLARED_SECRET = 'TEST_SECRET_DECLARED_24994'; |
| 44 | +const DECLARED_HEADER = 'TEST_HEADER_DECLARED_24994'; |
| 45 | +const SAVED_SECRET = 'TEST_SECRET_SAVED_24994'; |
| 46 | +const SAVED_HEADER = 'TEST_HEADER_SAVED_24994'; |
| 47 | +const PLACEHOLDERS = [DECLARED_SECRET, DECLARED_HEADER, SAVED_SECRET, SAVED_HEADER] as const; |
| 48 | + |
| 49 | +const DECLARED = 'wcr_declared'; |
| 50 | +const SAVED = 'wcr_saved'; |
| 51 | + |
| 52 | +const WcrTask = ObjectSchema.create({ |
| 53 | + name: 'wcr_task', |
| 54 | + sharingModel: 'public_read_write', |
| 55 | + label: 'WCR Task', |
| 56 | + pluralLabel: 'WCR Tasks', |
| 57 | + fields: { name: Field.text({ label: 'Name', required: true }) }, |
| 58 | +}); |
| 59 | + |
| 60 | +const fixture = defineStack({ |
| 61 | + manifest: { |
| 62 | + id: 'com.dogfood.webhook-credential-redaction', |
| 63 | + namespace: 'wcr', |
| 64 | + version: '0.0.0', |
| 65 | + type: 'app', |
| 66 | + name: 'Webhook Credential Redaction Fixture', |
| 67 | + description: 'One object and one declared webhook carrying both declared credential fields, for the meta read-door pins of ruling B+ on #24966.', |
| 68 | + }, |
| 69 | + objects: [WcrTask], |
| 70 | + webhooks: [ |
| 71 | + defineWebhook({ |
| 72 | + name: DECLARED, |
| 73 | + label: 'WCR Declared', |
| 74 | + object: 'wcr_task', |
| 75 | + triggers: ['create'], |
| 76 | + url: 'https://hooks.example/wcr-declared', |
| 77 | + headers: { 'X-Placeholder': DECLARED_HEADER }, |
| 78 | + secret: DECLARED_SECRET, |
| 79 | + isActive: false, |
| 80 | + }), |
| 81 | + ], |
| 82 | +}); |
| 83 | + |
| 84 | +const savedBody = () => ({ |
| 85 | + name: SAVED, |
| 86 | + label: 'WCR Saved', |
| 87 | + object: 'wcr_task', |
| 88 | + triggers: ['update'], |
| 89 | + url: 'https://hooks.example/wcr-saved', |
| 90 | + headers: { 'X-Placeholder': SAVED_HEADER }, |
| 91 | + secret: SAVED_SECRET, |
| 92 | + isActive: false, |
| 93 | +}); |
| 94 | + |
| 95 | +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []); |
| 96 | +const holdsAPlaceholder = (v: unknown): boolean => { |
| 97 | + const text = typeof v === 'string' ? v : JSON.stringify(v ?? null); |
| 98 | + return PLACEHOLDERS.some((p) => text.includes(p)); |
| 99 | +}; |
| 100 | +const bodyOf = (row: any): Record<string, unknown> => |
| 101 | + typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : (row?.metadata ?? {}); |
| 102 | + |
| 103 | +const COMPOSITIONS = [ |
| 104 | + { label: 'without plugin-webhooks', plugins: () => [] as unknown[] }, |
| 105 | + { label: 'with plugin-webhooks mounted', plugins: () => [new MessagingServicePlugin(), new WebhookOutboxPlugin()] }, |
| 106 | +] as const; |
| 107 | + |
| 108 | +for (const composition of COMPOSITIONS) { |
| 109 | + describe(`[ruling B+] every meta read door withholds a webhook's declared credentials — ${composition.label}`, () => { |
| 110 | + let stack: VerifyStack; |
| 111 | + let admin: string; |
| 112 | + let member: string; |
| 113 | + let ql: any; |
| 114 | + let protocol: any; |
| 115 | + |
| 116 | + const stored = async (object: 'sys_metadata' | 'sys_metadata_history', name: string) => |
| 117 | + rowsOf(await ql.find(object, { where: { type: 'webhook', name }, context: SYSTEM })); |
| 118 | + |
| 119 | + /** One door read: 200, names the webhook (positive control), and holds no credential. */ |
| 120 | + const expectWithheld = async (token: string, path: string, names: readonly string[]) => { |
| 121 | + const res = await stack.apiAs(token, 'GET', path); |
| 122 | + const text = await res.text(); |
| 123 | + expect(res.status, `${path}: ${text.slice(0, 200)}`).toBe(200); |
| 124 | + for (const name of names) expect(text.includes(name), `${path} names ${name}`).toBe(true); |
| 125 | + expect(holdsAPlaceholder(text), `${path} withholds the declared credential fields`).toBe(false); |
| 126 | + }; |
| 127 | + |
| 128 | + beforeAll(async () => { |
| 129 | + stack = await bootStack(fixture as never, { extraPlugins: composition.plugins() as never }); |
| 130 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 131 | + protocol = await stack.kernel.getServiceAsync('protocol'); |
| 132 | + admin = await stack.signIn(); |
| 133 | + member = await stack.signUp(`wcr-member-${composition.plugins().length}@redaction.test`); |
| 134 | + const put = await stack.apiAs(admin, 'PUT', `/meta/webhook/${SAVED}`, savedBody()); |
| 135 | + expect(put.status, await put.clone().text()).toBe(200); |
| 136 | + }, 180_000); |
| 137 | + |
| 138 | + afterAll(async () => { |
| 139 | + await stack?.stop?.(); |
| 140 | + }); |
| 141 | + |
| 142 | + it('guard the guard: the saved webhook is stored with both credential fields, and the plugin is mounted exactly when this composition says so', async () => { |
| 143 | + const [row] = await stored('sys_metadata', SAVED); |
| 144 | + expect(bodyOf(row).secret).toBe(SAVED_SECRET); |
| 145 | + expect(bodyOf(row).headers).toEqual({ 'X-Placeholder': SAVED_HEADER }); |
| 146 | + let declaredService: unknown; |
| 147 | + try { |
| 148 | + declaredService = stack.kernel.getService('declaredWebhooks'); |
| 149 | + } catch { |
| 150 | + declaredService = undefined; |
| 151 | + } |
| 152 | + expect(Boolean(declaredService)).toBe(composition.plugins().length > 0); |
| 153 | + }); |
| 154 | + |
| 155 | + it('the meta list, both spellings, withholds both fields of the declared and the saved webhook', async () => { |
| 156 | + for (const token of [admin, member]) { |
| 157 | + await expectWithheld(token, '/meta/webhook', [DECLARED, SAVED]); |
| 158 | + await expectWithheld(token, '/meta/webhooks', [DECLARED, SAVED]); |
| 159 | + } |
| 160 | + }); |
| 161 | + |
| 162 | + it('the item read and the layered view withhold both fields', async () => { |
| 163 | + for (const token of [admin, member]) { |
| 164 | + for (const name of [DECLARED, SAVED]) { |
| 165 | + await expectWithheld(token, `/meta/webhook/${name}`, [name]); |
| 166 | + await expectWithheld(token, `/meta/webhook/${name}/layers`, [name]); |
| 167 | + } |
| 168 | + } |
| 169 | + }); |
| 170 | + |
| 171 | + it('the published read withholds both fields', async () => { |
| 172 | + for (const token of [admin, member]) await expectWithheld(token, `/meta/webhook/${SAVED}/published`, [SAVED]); |
| 173 | + }); |
| 174 | + |
| 175 | + it('the metadata protocol\'s list and item reads withhold both fields', async () => { |
| 176 | + const listed = await protocol.getMetaItems({ type: 'webhook' }); |
| 177 | + expect(JSON.stringify(listed).includes(SAVED)).toBe(true); |
| 178 | + expect(holdsAPlaceholder(listed)).toBe(false); |
| 179 | + for (const name of [DECLARED, SAVED]) { |
| 180 | + const item = await protocol.getMetaItem({ type: 'webhook', name }); |
| 181 | + expect(JSON.stringify(item).includes(name), name).toBe(true); |
| 182 | + expect(holdsAPlaceholder(item), name).toBe(false); |
| 183 | + } |
| 184 | + }); |
| 185 | + |
| 186 | + it('the stored-row data door and its history twin withhold both fields', async () => { |
| 187 | + const filter = encodeURIComponent(JSON.stringify({ type: 'webhook', name: SAVED })); |
| 188 | + await expectWithheld(admin, `/data/sys_metadata?filter=${filter}`, [SAVED]); |
| 189 | + await expectWithheld(admin, `/data/sys_metadata_history?filter=${filter}`, [SAVED]); |
| 190 | + }); |
| 191 | + |
| 192 | + it('a save of the served form keeps the stored values (the carry-forward), and the store is never redacted', async () => { |
| 193 | + const read = await stack.apiAs(admin, 'GET', `/meta/webhook/${SAVED}`); |
| 194 | + expect(read.status).toBe(200); |
| 195 | + const json = (await read.json()) as any; |
| 196 | + const served = (json?.item ?? json?.data?.item ?? json?.data ?? json) as Record<string, unknown>; |
| 197 | + expect(served?.name).toBe(SAVED); |
| 198 | + const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_'))); |
| 199 | + expect(Object.keys(body)).not.toContain('secret'); |
| 200 | + expect(Object.keys(body)).not.toContain('headers'); |
| 201 | + |
| 202 | + const put = await stack.apiAs(admin, 'PUT', `/meta/webhook/${SAVED}`, { ...body, label: 'WCR Saved (edited)' }); |
| 203 | + expect(put.status, await put.clone().text()).toBe(200); |
| 204 | + |
| 205 | + const [row] = await stored('sys_metadata', SAVED); |
| 206 | + expect(bodyOf(row).label).toBe('WCR Saved (edited)'); |
| 207 | + expect(bodyOf(row).secret).toBe(SAVED_SECRET); |
| 208 | + expect(bodyOf(row).headers).toEqual({ 'X-Placeholder': SAVED_HEADER }); |
| 209 | + }); |
| 210 | + |
| 211 | + if (composition.plugins().length > 0) { |
| 212 | + it('the dispatcher\'s declared-webhook source still carries both credentials: delivery signing is unaffected', async () => { |
| 213 | + const declaredWebhooks = (await stack.kernel.getServiceAsync('declaredWebhooks')) as any; |
| 214 | + const declared = await declaredWebhooks.declaredWebhookNamed(DECLARED); |
| 215 | + expect(declared?.webhook?.secret).toBe(DECLARED_SECRET); |
| 216 | + expect(declared?.webhook?.headers).toEqual({ 'X-Placeholder': DECLARED_HEADER }); |
| 217 | + }); |
| 218 | + } |
| 219 | + }); |
| 220 | +} |
0 commit comments