|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22510] `sys_activity.actor_name` is WRITTEN — the acting user's display |
| 5 | + * name, captured when the activity row is written. |
| 6 | + * |
| 7 | + * `sys_activity` declares the column (and lists it among its highlight |
| 8 | + * fields) because its entries are "denormalized snapshots" read |
| 9 | + * chronologically. No writer filled it: every row carried `actor_id` and a |
| 10 | + * null `actor_name`, and the record History tab — which reads `actor_name` — |
| 11 | + * showed "Unknown user" for every entry, for every user. |
| 12 | + * |
| 13 | + * Pinned through the REAL engine (`ObjectQL`), so the user travels the way it |
| 14 | + * does in production — `ExecutionContext.userId` → the hook session — rather |
| 15 | + * than as a hand-built hook context: |
| 16 | + * |
| 17 | + * 1. a user's write names that user, on create, update and delete alike; |
| 18 | + * 2. `actor_id` is unchanged, and is the id the name was read for (control); |
| 19 | + * 3. a user whose name cannot be read leaves `actor_name` empty — never the |
| 20 | + * id, which would read as a plausible name; |
| 21 | + * 4. a write with no user leaves it empty too: ADR-0118 D1 keeps the system |
| 22 | + * actor `null`, and its "System" label is a rendering rule, not data; |
| 23 | + * 5. the read is one per user, not one per row — `writeAudit` runs once per |
| 24 | + * row of a predicate write, and this is its hot path. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, it, expect } from 'vitest'; |
| 28 | +import { ObjectQL } from '@objectstack/objectql'; |
| 29 | +import { installAuditWriters } from './audit-writers.js'; |
| 30 | + |
| 31 | +const f = (name: string, type: string, extra: Record<string, unknown> = {}) => |
| 32 | + ({ name, label: name, type, ...extra }) as any; |
| 33 | + |
| 34 | +const sysAuditLog = { |
| 35 | + name: 'sys_audit_log', label: 'Audit Log', |
| 36 | + fields: { |
| 37 | + id: f('id', 'text', { primaryKey: true }), action: f('action', 'text'), |
| 38 | + user_id: f('user_id', 'text'), object_name: f('object_name', 'text'), |
| 39 | + record_id: f('record_id', 'text'), old_value: f('old_value', 'textarea'), |
| 40 | + new_value: f('new_value', 'textarea'), tenant_id: f('tenant_id', 'text'), |
| 41 | + }, |
| 42 | +}; |
| 43 | + |
| 44 | +/** The two actor columns the shipped `sys_activity` declares, both present. */ |
| 45 | +const sysActivity = { |
| 46 | + name: 'sys_activity', label: 'Activity', |
| 47 | + fields: { |
| 48 | + id: f('id', 'text', { primaryKey: true }), type: f('type', 'text'), |
| 49 | + timestamp: f('timestamp', 'datetime'), summary: f('summary', 'text'), |
| 50 | + actor_id: f('actor_id', 'text'), actor_name: f('actor_name', 'text'), |
| 51 | + object_name: f('object_name', 'text'), record_id: f('record_id', 'text'), |
| 52 | + record_label: f('record_label', 'text'), metadata: f('metadata', 'textarea'), |
| 53 | + }, |
| 54 | +}; |
| 55 | + |
| 56 | +/** `nameField: 'name'`, as the shipped `sys_user` declares it. */ |
| 57 | +const sysUser = { |
| 58 | + name: 'sys_user', label: 'User', nameField: 'name', |
| 59 | + fields: { |
| 60 | + id: f('id', 'text', { primaryKey: true }), name: f('name', 'text'), |
| 61 | + email: f('email', 'email'), |
| 62 | + }, |
| 63 | +}; |
| 64 | + |
| 65 | +const bizTask = { |
| 66 | + name: 'biz_task', label: 'Task', |
| 67 | + fields: { id: f('id', 'text', { primaryKey: true }), title: f('title', 'text'), status: f('status', 'text') }, |
| 68 | +}; |
| 69 | + |
| 70 | +/** `enable.activities: false` — this object's writes produce no activity row. */ |
| 71 | +const bizQuiet = { |
| 72 | + name: 'biz_quiet', label: 'Quiet', enable: { activities: false }, |
| 73 | + fields: { id: f('id', 'text', { primaryKey: true }), title: f('title', 'text') }, |
| 74 | +}; |
| 75 | + |
| 76 | +/** Copy-returning, read-counting store — the shape `audit-lookup-summary.test.ts` measures with. */ |
| 77 | +function makeCountingDriver() { |
| 78 | + const stores = new Map<string, Map<string, Record<string, unknown>>>(); |
| 79 | + const reads: Record<string, number> = {}; |
| 80 | + const storeFor = (o: string) => { |
| 81 | + let s = stores.get(o); |
| 82 | + if (!s) { s = new Map(); stores.set(o, s); } |
| 83 | + return s; |
| 84 | + }; |
| 85 | + let nextId = 0; |
| 86 | + const copy = <T,>(r: T): T => (r == null ? r : JSON.parse(JSON.stringify(r))); |
| 87 | + const matches = (row: Record<string, unknown>, where: any): boolean => { |
| 88 | + if (!where || typeof where !== 'object') return true; |
| 89 | + for (const [k, v] of Object.entries<any>(where)) { |
| 90 | + if (k === '$and' && Array.isArray(v)) { |
| 91 | + if (!v.every((sub) => matches(row, sub))) return false; |
| 92 | + continue; |
| 93 | + } |
| 94 | + if (k.startsWith('$')) continue; |
| 95 | + if (v && typeof v === 'object' && '$in' in v) { |
| 96 | + if (!(v.$in as unknown[]).includes(row[k])) return false; |
| 97 | + continue; |
| 98 | + } |
| 99 | + const expected = (v && typeof v === 'object' && '$eq' in v) ? v.$eq : v; |
| 100 | + if ((row[k] ?? null) !== (expected ?? null)) return false; |
| 101 | + } |
| 102 | + return true; |
| 103 | + }; |
| 104 | + const driver: any = { |
| 105 | + name: 'memory', version: '0.0.0', supports: {} as any, |
| 106 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, |
| 107 | + async execute() { return null; }, |
| 108 | + async find(object: string, ast: any) { |
| 109 | + reads[object] = (reads[object] ?? 0) + 1; |
| 110 | + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map(copy); |
| 111 | + }, |
| 112 | + async findOne(object: string, ast: any) { |
| 113 | + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return copy(r); |
| 114 | + return null; |
| 115 | + }, |
| 116 | + async create(object: string, data: Record<string, unknown>) { |
| 117 | + nextId += 1; |
| 118 | + const id = (data.id as string) ?? `r_${nextId}`; |
| 119 | + const row = { ...data, id }; |
| 120 | + storeFor(object).set(id, row); |
| 121 | + return copy(row); |
| 122 | + }, |
| 123 | + async update(object: string, id: string, data: Record<string, unknown>) { |
| 124 | + const s = storeFor(object); |
| 125 | + const cur = s.get(id); |
| 126 | + if (!cur) return null; |
| 127 | + const updated = { ...cur, ...data, id }; |
| 128 | + s.set(id, updated); |
| 129 | + return copy(updated); |
| 130 | + }, |
| 131 | + async upsert(object: string, data: Record<string, unknown>) { |
| 132 | + const id = data.id as string | undefined; |
| 133 | + return id && storeFor(object).has(id) ? this.update(object, id, data) : this.create(object, data); |
| 134 | + }, |
| 135 | + async delete(object: string, id: string) { return storeFor(object).delete(id); }, |
| 136 | + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, |
| 137 | + async bulkCreate(object: string, rows: Record<string, unknown>[]) { |
| 138 | + return Promise.all(rows.map((r) => this.create(object, r))); |
| 139 | + }, |
| 140 | + async bulkUpdate() { return []; }, |
| 141 | + async bulkDelete() {}, |
| 142 | + async updateMany(object: string, ast: any, data: Record<string, unknown>) { |
| 143 | + const rows = await this.find(object, ast); |
| 144 | + const s = storeFor(object); |
| 145 | + for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); |
| 146 | + return rows.length; |
| 147 | + }, |
| 148 | + async deleteMany(object: string, ast: any) { |
| 149 | + const rows = await this.find(object, ast); |
| 150 | + for (const r of rows) storeFor(object).delete(r.id as string); |
| 151 | + return rows.length; |
| 152 | + }, |
| 153 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 154 | + async commit() {}, async rollback() {}, |
| 155 | + }; |
| 156 | + return { driver, reads, storeFor }; |
| 157 | +} |
| 158 | + |
| 159 | +const OWNER_PACKAGE = 'com.objectstack.test.activity-actor-name'; |
| 160 | +const ADA = 'usr_ada'; |
| 161 | + |
| 162 | +async function boot() { |
| 163 | + const engine = new ObjectQL(); |
| 164 | + const stub = makeCountingDriver(); |
| 165 | + engine.registerDriver(stub.driver, true); |
| 166 | + await engine.init(); |
| 167 | + for (const o of [sysAuditLog, sysActivity, sysUser, bizTask, bizQuiet]) { |
| 168 | + engine.registry.registerObject(o as any, OWNER_PACKAGE); |
| 169 | + } |
| 170 | + installAuditWriters(engine as any, 'test.audit'); |
| 171 | + // Seeded with no context: a system write, which names nobody. |
| 172 | + await engine.insert('sys_user', { id: ADA, name: 'Ada Lovelace', email: 'ada@example.com' }); |
| 173 | + return { engine, ...stub }; |
| 174 | +} |
| 175 | + |
| 176 | +const as = (userId: string) => ({ context: { userId } }); |
| 177 | + |
| 178 | +/** Every activity row written about `recordId`, in write order. */ |
| 179 | +const activityFor = (storeFor: (o: string) => Map<string, Record<string, unknown>>, recordId: string) => |
| 180 | + Array.from(storeFor('sys_activity').values()).filter((r) => r.record_id === recordId); |
| 181 | + |
| 182 | +describe('[#22510] sys_activity.actor_name — the acting user\'s name, written with the row', () => { |
| 183 | + it('a user\'s create, update and delete each carry that user\'s name', async () => { |
| 184 | + const { engine, storeFor } = await boot(); |
| 185 | + |
| 186 | + await engine.insert('biz_task', { id: 't1', title: 'Call Acme', status: 'open' }, as(ADA) as any); |
| 187 | + await engine.update('biz_task', { status: 'done' }, { where: { id: 't1' }, ...as(ADA) } as any); |
| 188 | + await engine.delete('biz_task', { where: { id: 't1' }, ...as(ADA) } as any); |
| 189 | + |
| 190 | + const rows = activityFor(storeFor, 't1'); |
| 191 | + expect(rows.map((r) => r.type)).toEqual(['created', 'updated', 'deleted']); |
| 192 | + for (const row of rows) { |
| 193 | + expect(row.actor_name).toBe('Ada Lovelace'); |
| 194 | + // Control: `actor_id` is exactly what it was — the same id the name was read for. |
| 195 | + expect(row.actor_id).toBe(ADA); |
| 196 | + } |
| 197 | + }); |
| 198 | + |
| 199 | + it('names the human a system-authorized write is ATTRIBUTED to — the same id `actor_id` records', async () => { |
| 200 | + const { engine, storeFor } = await boot(); |
| 201 | + |
| 202 | + // The better-auth envelope: authorized as the system, crediting a human |
| 203 | + // through provenance (`attributedUserId`) rather than the session. |
| 204 | + await engine.insert( |
| 205 | + 'biz_task', |
| 206 | + { id: 't2', title: 'Grade change', status: 'open' }, |
| 207 | + { context: { isSystem: true, attributedUserId: ADA } } as any, |
| 208 | + ); |
| 209 | + |
| 210 | + const [row] = activityFor(storeFor, 't2'); |
| 211 | + expect(row.actor_id).toBe(ADA); |
| 212 | + expect(row.actor_name).toBe('Ada Lovelace'); |
| 213 | + }); |
| 214 | + |
| 215 | + it('a user whose name cannot be read leaves actor_name empty — never the id', async () => { |
| 216 | + const { engine, storeFor } = await boot(); |
| 217 | + await engine.insert('sys_user', { id: 'usr_blank', name: ' ', email: 'blank@example.com' }); |
| 218 | + |
| 219 | + // No `sys_user` row at all, and a row whose name is blank. |
| 220 | + await engine.insert('biz_task', { id: 't3', title: 'Ghost', status: 'open' }, as('usr_ghost') as any); |
| 221 | + await engine.insert('biz_task', { id: 't4', title: 'Blank', status: 'open' }, as('usr_blank') as any); |
| 222 | + |
| 223 | + const [ghost] = activityFor(storeFor, 't3'); |
| 224 | + const [blank] = activityFor(storeFor, 't4'); |
| 225 | + expect(ghost.actor_id).toBe('usr_ghost'); |
| 226 | + expect(ghost.actor_name ?? null).toBeNull(); |
| 227 | + expect(blank.actor_id).toBe('usr_blank'); |
| 228 | + expect(blank.actor_name ?? null).toBeNull(); |
| 229 | + }); |
| 230 | + |
| 231 | + it('a write with no user leaves actor_name empty (ADR-0118 D1: the system actor is null) and reads no user', async () => { |
| 232 | + const { engine, storeFor, reads } = await boot(); |
| 233 | + const before = reads.sys_user ?? 0; |
| 234 | + |
| 235 | + await engine.insert('biz_task', { id: 't5', title: 'Nightly', status: 'open' }, { context: { isSystem: true } } as any); |
| 236 | + // A service principal on `actor` is not a user either (ADR-0118 D5: user or system). |
| 237 | + await engine.insert( |
| 238 | + 'biz_task', |
| 239 | + { id: 't6', title: 'Flow', status: 'open' }, |
| 240 | + { context: { isSystem: true, actor: 'svc:flow:nightly' } } as any, |
| 241 | + ); |
| 242 | + |
| 243 | + for (const id of ['t5', 't6']) { |
| 244 | + const [row] = activityFor(storeFor, id); |
| 245 | + expect(row.actor_id).toBeNull(); |
| 246 | + expect(row.actor_name ?? null).toBeNull(); |
| 247 | + } |
| 248 | + expect((reads.sys_user ?? 0) - before).toBe(0); |
| 249 | + }); |
| 250 | +}); |
| 251 | + |
| 252 | +describe('[#22510] the name costs one read per user, not one per row', () => { |
| 253 | + it('a predicate update over three rows reads sys_user ONCE, and a later write by the same user not at all', async () => { |
| 254 | + const { engine, storeFor, reads } = await boot(); |
| 255 | + for (const id of ['p1', 'p2', 'p3']) { |
| 256 | + await engine.insert('biz_task', { id, title: id, status: 'open' }); |
| 257 | + } |
| 258 | + const before = reads.sys_user ?? 0; |
| 259 | + |
| 260 | + await engine.update('biz_task', { status: 'done' }, { where: { status: 'open' }, multi: true, ...as(ADA) } as any); |
| 261 | + |
| 262 | + const updated = Array.from(storeFor('sys_activity').values()).filter((r) => r.type === 'updated'); |
| 263 | + expect(updated).toHaveLength(3); |
| 264 | + for (const row of updated) expect(row.actor_name).toBe('Ada Lovelace'); |
| 265 | + expect((reads.sys_user ?? 0) - before).toBe(1); |
| 266 | + |
| 267 | + await engine.update('biz_task', { title: 'renamed' }, { where: { id: 'p1' }, ...as(ADA) } as any); |
| 268 | + expect((reads.sys_user ?? 0) - before).toBe(1); |
| 269 | + }); |
| 270 | + |
| 271 | + it('an object with `enable.activities: false` writes no activity row and reads no user', async () => { |
| 272 | + const { engine, storeFor, reads } = await boot(); |
| 273 | + const before = reads.sys_user ?? 0; |
| 274 | + |
| 275 | + await engine.insert('biz_quiet', { id: 'q1', title: 'Quiet' }, as(ADA) as any); |
| 276 | + |
| 277 | + expect(activityFor(storeFor, 'q1')).toHaveLength(0); |
| 278 | + expect((reads.sys_user ?? 0) - before).toBe(0); |
| 279 | + }); |
| 280 | +}); |
0 commit comments