|
2 | 2 |
|
3 | 3 | import { describe, it, expect, beforeEach } from 'vitest'; |
4 | 4 | import { MessagingService } from './messaging-service.js'; |
| 5 | +import { InboxCallerError } from './inbox-caller.js'; |
5 | 6 | import { MemoryNotificationOutbox } from './memory-outbox.js'; |
6 | 7 | import type { Delivery, MessagingChannel, SendResult } from './channel.js'; |
7 | 8 |
|
@@ -1070,3 +1071,127 @@ describe('[#6436] markAllRead — sweeps the whole inbox, not one 200-row window |
1070 | 1071 | expect(await svc.markAllRead('')).toEqual({ success: true, readCount: 0 }); |
1071 | 1072 | }); |
1072 | 1073 | }); |
| 1074 | + |
| 1075 | +/** |
| 1076 | + * [#10753] The plugin-facing inbox write door. |
| 1077 | + * |
| 1078 | + * The measured BEFORE, and it is this card's real severity: the messaging |
| 1079 | + * service is registered as a kernel service and the kernel hands every plugin |
| 1080 | + * ONE shared `PluginContext` whose `getService` carries no caller identity, so |
| 1081 | + * `markRead(userId, ids)`'s first parameter is a free string for an in-process |
| 1082 | + * caller — any plugin could mark ANY user's inbox messages read, unconstrained |
| 1083 | + * and undeclared, with the receipt landing context-lessly on an `engine-owned` |
| 1084 | + * object so no engine permission check saw it either. |
| 1085 | + * |
| 1086 | + * `markReadAsCaller` / `markAllReadAsCaller` take no target user at all. These |
| 1087 | + * pin that the recipient comes from the caller's authenticated `userId` and |
| 1088 | + * from NOTHING that merely resembles one. |
| 1089 | + */ |
| 1090 | +describe('MessagingService — plugin-facing inbox writes scoped to the authenticated caller (#10753)', () => { |
| 1091 | + const logger = silentLogger(); |
| 1092 | + |
| 1093 | + /** u1 and u2 each hold one unread message, so cross-user reach is visible. */ |
| 1094 | + function twoUserInbox() { |
| 1095 | + return inboxEngine({ |
| 1096 | + inbox: [ |
| 1097 | + { id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'Approve me', created_at: '1' }, |
| 1098 | + { id: 'm2', user_id: 'u2', notification_id: 'n2', title: 'Approve me too', created_at: '2' }, |
| 1099 | + ], |
| 1100 | + receipts: [ |
| 1101 | + { id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'delivered' }, |
| 1102 | + { id: 'r2', notification_id: 'n2', user_id: 'u2', channel: 'inbox', state: 'delivered' }, |
| 1103 | + ], |
| 1104 | + }); |
| 1105 | + } |
| 1106 | + |
| 1107 | + it("marks the caller's OWN message read", async () => { |
| 1108 | + const engine = twoUserInbox(); |
| 1109 | + const svc = new MessagingService({ logger, getData: () => engine }); |
| 1110 | + |
| 1111 | + expect(await svc.markReadAsCaller({ userId: 'u1' }, ['n1'])).toEqual({ success: true, readCount: 1 }); |
| 1112 | + expect((await svc.listInbox('u1')).unreadCount).toBe(0); |
| 1113 | + }); |
| 1114 | + |
| 1115 | + it("cannot reach another user's read-state even when handed their notification id", async () => { |
| 1116 | + // The id is not secret — `sys_notification` publishes get/list and every |
| 1117 | + // recipient's own `listInbox` hands them out — so "holding the id" was |
| 1118 | + // never a capability. What makes u2 unreachable is that the receipt is |
| 1119 | + // keyed `(notification_id, user_id, channel)` and the user half comes |
| 1120 | + // from the CALLER, which this surface does not let you name. |
| 1121 | + const engine = twoUserInbox(); |
| 1122 | + const svc = new MessagingService({ logger, getData: () => engine }); |
| 1123 | + |
| 1124 | + await svc.markReadAsCaller({ userId: 'u1' }, ['n2']); |
| 1125 | + |
| 1126 | + expect((await svc.listInbox('u2')).unreadCount).toBe(1); |
| 1127 | + expect(engine.store.sys_notification_receipt.find((r: any) => r.user_id === 'u2').state).toBe('delivered'); |
| 1128 | + }); |
| 1129 | + |
| 1130 | + it("sweeps only the caller's own inbox on markAllReadAsCaller", async () => { |
| 1131 | + const engine = twoUserInbox(); |
| 1132 | + const svc = new MessagingService({ logger, getData: () => engine }); |
| 1133 | + |
| 1134 | + expect(await svc.markAllReadAsCaller({ userId: 'u1' })).toEqual({ success: true, readCount: 1 }); |
| 1135 | + expect((await svc.listInbox('u1')).unreadCount).toBe(0); |
| 1136 | + expect((await svc.listInbox('u2')).unreadCount).toBe(1); |
| 1137 | + }); |
| 1138 | + |
| 1139 | + describe('refusals — the ADR-0112 envelope, not a silent success', () => { |
| 1140 | + const svc = () => new MessagingService({ logger, getData: () => twoUserInbox() }); |
| 1141 | + |
| 1142 | + /** Assert the declared envelope pair a boundary reads: `status` + `code`. */ |
| 1143 | + async function expectRefusal(run: () => Promise<unknown>): Promise<InboxCallerError> { |
| 1144 | + const err = await run().then( |
| 1145 | + () => { throw new Error('expected InboxCallerError, but the call resolved'); }, |
| 1146 | + (e: unknown) => e as InboxCallerError, |
| 1147 | + ); |
| 1148 | + expect(err).toBeInstanceOf(InboxCallerError); |
| 1149 | + expect(err.code).toBe('UNAUTHENTICATED'); |
| 1150 | + expect(err.status).toBe(401); |
| 1151 | + return err; |
| 1152 | + } |
| 1153 | + |
| 1154 | + it('refuses an absent context', async () => { |
| 1155 | + await expectRefusal(() => svc().markReadAsCaller(undefined, ['n1'])); |
| 1156 | + await expectRefusal(() => svc().markAllReadAsCaller(undefined)); |
| 1157 | + }); |
| 1158 | + |
| 1159 | + it('refuses a context with no userId, and a blank one', async () => { |
| 1160 | + await expectRefusal(() => svc().markReadAsCaller({}, ['n1'])); |
| 1161 | + await expectRefusal(() => svc().markReadAsCaller({ userId: ' ' }, ['n1'])); |
| 1162 | + }); |
| 1163 | + |
| 1164 | + it('refuses a context carrying only attributedUserId — attribution never becomes authorization', async () => { |
| 1165 | + // `attributedUserId` is the real human behind a write whose |
| 1166 | + // authorization subject is the SYSTEM (#4586). Its own contract |
| 1167 | + // states the invariant — "nothing in the authorization path reads |
| 1168 | + // this", and a context carrying only it authorizes ANONYMOUS |
| 1169 | + // (ADR-0118 D2). A `userId ?? attributedUserId` fallback here would |
| 1170 | + // read as working and clear the wrong person's badge. |
| 1171 | + const engine = twoUserInbox(); |
| 1172 | + const service = new MessagingService({ logger, getData: () => engine }); |
| 1173 | + |
| 1174 | + const err = await expectRefusal(() => service.markReadAsCaller({ attributedUserId: 'u1' }, ['n1'])); |
| 1175 | + expect(err.message).toContain('attributedUserId'); |
| 1176 | + |
| 1177 | + // The refusal is the point, but so is this: u1's message is still unread. |
| 1178 | + expect((await service.listInbox('u1')).unreadCount).toBe(1); |
| 1179 | + }); |
| 1180 | + |
| 1181 | + it('refuses a service-principal label and a system context — neither owns an inbox', async () => { |
| 1182 | + await expectRefusal(() => svc().markReadAsCaller({ actor: 'svc:flow:nightly' }, ['n1'])); |
| 1183 | + await expectRefusal(() => svc().markAllReadAsCaller({ isSystem: true })); |
| 1184 | + }); |
| 1185 | + |
| 1186 | + it('refuses BEFORE the empty-ids and no-data-engine short-circuits', async () => { |
| 1187 | + // Both of those return `{ success: true, readCount: 0 }`. Reaching |
| 1188 | + // one with no authenticated caller would report success for a write |
| 1189 | + // that was never authorized — the silent-success shape this door |
| 1190 | + // exists to replace, and the reason the order is pinned rather than |
| 1191 | + // left to reading. |
| 1192 | + await expectRefusal(() => svc().markReadAsCaller(undefined, [])); |
| 1193 | + await expectRefusal(() => new MessagingService({ logger }).markReadAsCaller(undefined, ['n1'])); |
| 1194 | + await expectRefusal(() => new MessagingService({ logger }).markAllReadAsCaller({})); |
| 1195 | + }); |
| 1196 | + }); |
| 1197 | +}); |
0 commit comments