|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A `data.record.*` event is an external exit of the write it describes: its |
| 5 | + * subscribers store or forward the record bodies (`after`, `changes`) to |
| 6 | + * readers below the write boundary. So both bodies carry what a write |
| 7 | + * RESPONSE carries — credential-class fields masked, `internal: true` fields |
| 8 | + * omitted, by the one helper every write response uses — while the engine's |
| 9 | + * own write result, read by privileged in-process callers, stays whole. |
| 10 | + */ |
| 11 | + |
| 12 | +import { describe, it, expect, beforeEach, vi } from 'vitest'; |
| 13 | +import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; |
| 14 | +import { SECRET_MASK } from '@objectstack/spec/data'; |
| 15 | +import { ObjectQL } from './engine.js'; |
| 16 | + |
| 17 | +const CREDENTIAL = 'synthetic-credential-7c1e'; |
| 18 | +const INTERNAL = 'synthetic-internal-0b42'; |
| 19 | + |
| 20 | +const widget = { |
| 21 | + name: 'widget', |
| 22 | + label: 'Widget', |
| 23 | + fields: { |
| 24 | + id: { name: 'id', type: 'text' as const, primaryKey: true }, |
| 25 | + title: { name: 'title', type: 'text' as const }, |
| 26 | + passphrase: { name: 'passphrase', type: 'password' as const }, |
| 27 | + lookup_digest: { name: 'lookup_digest', type: 'text' as const, internal: true }, |
| 28 | + }, |
| 29 | +}; |
| 30 | + |
| 31 | +function makeStubDriver() { |
| 32 | + const stores = new Map<string, Map<string, Record<string, unknown>>>(); |
| 33 | + const storeFor = (o: string) => { |
| 34 | + let s = stores.get(o); |
| 35 | + if (!s) { s = new Map(); stores.set(o, s); } |
| 36 | + return s; |
| 37 | + }; |
| 38 | + let nextId = 0; |
| 39 | + const driver: any = { |
| 40 | + name: 'memory', version: '0.0.0', supports: {}, |
| 41 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 42 | + async find(o: string) { return Array.from(storeFor(o).values()); }, |
| 43 | + findStream() { throw new Error('ns'); }, |
| 44 | + async findOne(o: string, ast: any) { |
| 45 | + const where = ast?.where ?? {}; |
| 46 | + for (const r of storeFor(o).values()) { |
| 47 | + if (Object.entries(where).every(([k, v]) => k.startsWith('$') || (r[k] ?? null) === ((v as any)?.$eq ?? v ?? null))) return r; |
| 48 | + } |
| 49 | + return null; |
| 50 | + }, |
| 51 | + async create(o: string, data: Record<string, unknown>) { |
| 52 | + nextId += 1; |
| 53 | + const id = (data.id as string) ?? `r_${nextId}`; |
| 54 | + const row = { ...data, id }; |
| 55 | + storeFor(o).set(id, row); |
| 56 | + return row; |
| 57 | + }, |
| 58 | + async update(o: string, id: string, data: Record<string, unknown>) { |
| 59 | + const s = storeFor(o); const cur = s.get(id); |
| 60 | + if (!cur) throw new Error(`nf ${o}/${id}`); |
| 61 | + const up = { ...cur, ...data, id }; s.set(id, up); return up; |
| 62 | + }, |
| 63 | + async delete(o: string, id: string) { return storeFor(o).delete(id); }, |
| 64 | + async count(o: string) { return (await this.find(o)).length; }, |
| 65 | + async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, |
| 66 | + async updateMany() { return 0; }, async deleteMany() { return 0; }, |
| 67 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 68 | + async upsert(o: string, data: Record<string, unknown>) { return this.create(o, data); }, |
| 69 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 70 | + async commit() {}, async rollback() {}, |
| 71 | + }; |
| 72 | + return { driver }; |
| 73 | +} |
| 74 | + |
| 75 | +describe('record-change event bodies apply the write-response non-exposure rules', () => { |
| 76 | + let engine: ObjectQL; |
| 77 | + let published: RealtimeEventPayload[]; |
| 78 | + |
| 79 | + beforeEach(async () => { |
| 80 | + published = []; |
| 81 | + const realtime: IRealtimeService = { |
| 82 | + publish: vi.fn(async (event: RealtimeEventPayload) => { published.push(event); }), |
| 83 | + subscribe: vi.fn(async () => 'sub-1'), |
| 84 | + unsubscribe: vi.fn(async () => undefined), |
| 85 | + }; |
| 86 | + engine = new ObjectQL(); |
| 87 | + const { driver } = makeStubDriver(); |
| 88 | + engine.registerDriver(driver, true); |
| 89 | + await engine.init(); |
| 90 | + engine.registry.registerObject(widget); |
| 91 | + engine.setRealtimeService(realtime); |
| 92 | + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); |
| 93 | + vi.spyOn((engine as any).logger, 'debug').mockImplementation(() => undefined); |
| 94 | + }); |
| 95 | + |
| 96 | + it('a create event masks the credential field and omits the internal field from `after`', async () => { |
| 97 | + await engine.insert('widget', { id: 'w1', title: 'One', passphrase: CREDENTIAL, lookup_digest: INTERNAL }); |
| 98 | + expect(published).toHaveLength(1); |
| 99 | + const payload = published[0].payload as Record<string, any>; |
| 100 | + expect(payload.type).toBe('data.record.created'); |
| 101 | + expect(payload.after.title).toBe('One'); |
| 102 | + expect(payload.after.passphrase).toBe(SECRET_MASK); |
| 103 | + expect('lookup_digest' in payload.after).toBe(false); |
| 104 | + expect(JSON.stringify(payload)).not.toContain(CREDENTIAL); |
| 105 | + expect(JSON.stringify(payload)).not.toContain(INTERNAL); |
| 106 | + }); |
| 107 | + |
| 108 | + it('an update event projects both `after` and `changes`', async () => { |
| 109 | + await engine.insert('widget', { id: 'w2', title: 'Two', passphrase: 'first', lookup_digest: 'first' }); |
| 110 | + published.length = 0; |
| 111 | + await engine.update('widget', { id: 'w2', title: 'Two b', passphrase: CREDENTIAL, lookup_digest: INTERNAL }); |
| 112 | + expect(published).toHaveLength(1); |
| 113 | + const payload = published[0].payload as Record<string, any>; |
| 114 | + expect(payload.type).toBe('data.record.updated'); |
| 115 | + expect(payload.changes.title).toBe('Two b'); |
| 116 | + expect(payload.changes.passphrase).toBe(SECRET_MASK); |
| 117 | + expect('lookup_digest' in payload.changes).toBe(false); |
| 118 | + expect(payload.after.passphrase).toBe(SECRET_MASK); |
| 119 | + expect('lookup_digest' in payload.after).toBe(false); |
| 120 | + expect(JSON.stringify(payload)).not.toContain(CREDENTIAL); |
| 121 | + expect(JSON.stringify(payload)).not.toContain(INTERNAL); |
| 122 | + }); |
| 123 | + |
| 124 | + it('an unset credential field rides the event as null, not as the mask', async () => { |
| 125 | + await engine.insert('widget', { id: 'w3', title: 'Three', passphrase: null }); |
| 126 | + const payload = published[0].payload as Record<string, any>; |
| 127 | + expect(payload.after.passphrase).toBeNull(); |
| 128 | + }); |
| 129 | + |
| 130 | + it("the engine's own write result is unchanged for the privileged in-process caller", async () => { |
| 131 | + const created = await engine.insert('widget', { |
| 132 | + id: 'w4', title: 'Four', passphrase: CREDENTIAL, lookup_digest: INTERNAL, |
| 133 | + }) as Record<string, unknown>; |
| 134 | + expect(created.passphrase).toBe(CREDENTIAL); |
| 135 | + expect(created.lookup_digest).toBe(INTERNAL); |
| 136 | + |
| 137 | + const updated = await engine.update('widget', { id: 'w4', lookup_digest: `${INTERNAL}-b` }) as Record<string, unknown>; |
| 138 | + expect(updated.passphrase).toBe(CREDENTIAL); |
| 139 | + expect(updated.lookup_digest).toBe(`${INTERNAL}-b`); |
| 140 | + // ...while the events those writes published carried neither value. |
| 141 | + expect(JSON.stringify(published.map((e) => e.payload))).not.toContain(CREDENTIAL); |
| 142 | + expect(JSON.stringify(published.map((e) => e.payload))).not.toContain(INTERNAL); |
| 143 | + }); |
| 144 | +}); |
0 commit comments