|
2 | 2 |
|
3 | 3 | import { describe, it, expect, beforeEach } from 'vitest'; |
4 | 4 | import { ShareLinkService } from './share-link-service.js'; |
| 5 | +import { SharingService } from './sharing-service.js'; |
5 | 6 |
|
6 | 7 | interface FakeRow { [k: string]: any } |
7 | 8 |
|
@@ -1170,3 +1171,228 @@ describe('[#12981] a refused usage stamp is reported ONCE as a durability degrad |
1170 | 1171 | await expect(service.resolveToken(link.token)).resolves.not.toBeNull(); |
1171 | 1172 | }); |
1172 | 1173 | }); |
| 1174 | + |
| 1175 | +// ── [ADR-0111 D8 rule 1 — ruling 5950188467, A′] who may mint ──────────────── |
| 1176 | +// |
| 1177 | +// "`createLink` admits `visibility OR owner OR Modify-All bypass`, still behind |
| 1178 | +// the `publicSharing` opt-in and before its `eligibility` check. … A hierarchy |
| 1179 | +// manager still needs visibility to mint." The owner and bypass halves are |
| 1180 | +// `canManageShares`' own first two branches, so the link service here is wired |
| 1181 | +// to a REAL `SharingService` exactly as `SharingServicePlugin` wires it; only |
| 1182 | +// its probes (the security service, the enterprise hierarchy resolver, the |
| 1183 | +// tenancy posture) are doubles. |
| 1184 | +// |
| 1185 | +// The object is OWNER-PRIVATE: the double below refuses every non-system read |
| 1186 | +// of it the way the CRUD gate does on an `access.default: 'private'` object |
| 1187 | +// (`PERMISSION_DENIED` with `statusCode` 403), except for `u_reader`, the one |
| 1188 | +// principal who can see it. So on the visibility rule alone nobody else — |
| 1189 | +// the owner included — could ever mint. |
| 1190 | +describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the record owner, or Modify-All', () => { |
| 1191 | + const OWNER = 'u_owner'; |
| 1192 | + const STRANGER = 'u_stranger'; |
| 1193 | + const ADMIN = 'u_admin'; |
| 1194 | + const MANAGER = 'u_manager'; |
| 1195 | + const READER = 'u_reader'; |
| 1196 | + |
| 1197 | + const SCHEMAS = { |
| 1198 | + sys_share_link: { name: 'sys_share_link', fields: {} }, |
| 1199 | + conversations: { |
| 1200 | + name: 'conversations', |
| 1201 | + access: { default: 'private' }, |
| 1202 | + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, |
| 1203 | + fields: { id: {}, title: {}, owner_id: {} }, |
| 1204 | + }, |
| 1205 | + // The same object shape with an eligibility predicate, for the ordering pins. |
| 1206 | + briefs: { |
| 1207 | + name: 'briefs', |
| 1208 | + access: { default: 'private' }, |
| 1209 | + publicSharing: { enabled: true, eligibility: "record.status == 'published'" }, |
| 1210 | + fields: { id: {}, title: {}, status: {}, owner_id: {} }, |
| 1211 | + }, |
| 1212 | + // Never opted in. |
| 1213 | + notes: { name: 'notes', access: { default: 'private' }, fields: { id: {}, owner_id: {} } }, |
| 1214 | + }; |
| 1215 | + const PRIVATE_OBJECTS = new Set(['conversations', 'briefs', 'notes']); |
| 1216 | + |
| 1217 | + const denial = () => |
| 1218 | + Object.assign(new Error('You do not have permission to perform this action.'), { |
| 1219 | + code: 'PERMISSION_DENIED', |
| 1220 | + statusCode: 403, |
| 1221 | + }); |
| 1222 | + |
| 1223 | + let engine: ReturnType<typeof makeFakeEngine>; |
| 1224 | + let posture: string | undefined; |
| 1225 | + let writeScopeCalls: number; |
| 1226 | + let sharing: SharingService; |
| 1227 | + let service: ShareLinkService; |
| 1228 | + let mintProbeCalls: Array<[string, string, string | undefined]>; |
| 1229 | + |
| 1230 | + const mintIn = (object: string, recordId: string) => |
| 1231 | + ({ object, recordId, audience: 'link_only', permission: 'view' }) as const; |
| 1232 | + const conversation = () => mintIn('conversations', 'c1'); |
| 1233 | + const as = (userId: string) => ({ userId }) as any; |
| 1234 | + const minted = () => (engine._tables.sys_share_link ?? []).map((r) => r.created_by); |
| 1235 | + |
| 1236 | + beforeEach(() => { |
| 1237 | + engine = makeFakeEngine(SCHEMAS); |
| 1238 | + engine._tables.conversations = [{ id: 'c1', title: 'My chat', owner_id: OWNER }]; |
| 1239 | + engine._tables.briefs = [ |
| 1240 | + { id: 'b_draft', title: 'Draft', status: 'draft', owner_id: OWNER }, |
| 1241 | + { id: 'b_pub', title: 'Published', status: 'published', owner_id: OWNER }, |
| 1242 | + ]; |
| 1243 | + engine._tables.notes = [{ id: 'n1', owner_id: OWNER }]; |
| 1244 | + const plainFind = engine.find.bind(engine); |
| 1245 | + engine.find = async (object: string, options?: any) => { |
| 1246 | + const ctx = options?.context ?? {}; |
| 1247 | + if (PRIVATE_OBJECTS.has(object) && ctx.isSystem !== true && ctx.userId !== READER) throw denial(); |
| 1248 | + return plainFind(object, options); |
| 1249 | + }; |
| 1250 | + |
| 1251 | + posture = 'single'; |
| 1252 | + writeScopeCalls = 0; |
| 1253 | + mintProbeCalls = []; |
| 1254 | + sharing = new SharingService({ |
| 1255 | + engine: engine as any, |
| 1256 | + securityService: () => ({ |
| 1257 | + hasWriteBypass: async (_object: string, ctx: any) => ctx?.userId === ADMIN, |
| 1258 | + resolveWriteScope: async (_object: string, ctx: any) => { |
| 1259 | + writeScopeCalls += 1; |
| 1260 | + return ctx?.userId === MANAGER ? 'unit' : 'own'; |
| 1261 | + }, |
| 1262 | + }), |
| 1263 | + // The enterprise seam: the manager's `unit` covers the owner. |
| 1264 | + hierarchyResolver: () => |
| 1265 | + ({ |
| 1266 | + resolveOwnerIds: async (ctx: any, scope: string) => |
| 1267 | + ctx?.userId === MANAGER && scope === 'unit' ? [MANAGER, OWNER] : [String(ctx?.userId)], |
| 1268 | + }) as any, |
| 1269 | + tenancy: () => (posture === undefined ? null : { posture }), |
| 1270 | + }); |
| 1271 | + // Wired the way `SharingServicePlugin` wires it. |
| 1272 | + service = new ShareLinkService({ |
| 1273 | + engine: engine as any, |
| 1274 | + canManageShares: (o, r, c) => sharing.canManageShares(o, r, c), |
| 1275 | + canMintWithoutVisibility: (o, r, c) => { |
| 1276 | + mintProbeCalls.push([o, r, (c as any)?.userId]); |
| 1277 | + return sharing.canMintWithoutVisibility(o, r, c); |
| 1278 | + }, |
| 1279 | + }); |
| 1280 | + }); |
| 1281 | + |
| 1282 | + it('[persona] nobody but the reader sees the record: the owner\'s own read is refused', async () => { |
| 1283 | + await expect(engine.find('conversations', { where: { id: 'c1' }, context: as(OWNER) })) |
| 1284 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); |
| 1285 | + expect(await engine.find('conversations', { where: { id: 'c1' }, context: as(READER) })).toHaveLength(1); |
| 1286 | + }); |
| 1287 | + |
| 1288 | + it('the owner mints on an owner-private object, and the link resolves', async () => { |
| 1289 | + const link = await service.createLink(conversation(), as(OWNER)); |
| 1290 | + expect(link).toMatchObject({ object_name: 'conversations', record_id: 'c1', created_by: OWNER }); |
| 1291 | + expect(minted()).toEqual([OWNER]); |
| 1292 | + await expect(service.resolveToken(link.token)).resolves.toMatchObject({ link: { id: link.id } }); |
| 1293 | + }); |
| 1294 | + |
| 1295 | + it('a hierarchy manager without visibility is refused, though they ARE a share-manager of the record', async () => { |
| 1296 | + // The control: ADR-0111 D1 DEPTH admits this principal to manage shares. |
| 1297 | + expect(await sharing.canManageShares('conversations', 'c1', as(MANAGER))).toBe(true); |
| 1298 | + const callsBefore = writeScopeCalls; |
| 1299 | + |
| 1300 | + await expect(service.createLink(conversation(), as(MANAGER))) |
| 1301 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); |
| 1302 | + expect(minted()).toEqual([]); |
| 1303 | + // Minting never asked for the caller's write scope: the DEPTH branch is not |
| 1304 | + // part of mint authority, rather than a branch that happened to say no. |
| 1305 | + expect(writeScopeCalls, 'the mint consulted the hierarchy-depth probe').toBe(callsBefore); |
| 1306 | + }); |
| 1307 | + |
| 1308 | + it('a non-owner member is refused with the visibility read\'s own refusal', async () => { |
| 1309 | + await expect(service.createLink(conversation(), as(STRANGER))) |
| 1310 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); |
| 1311 | + expect(minted()).toEqual([]); |
| 1312 | + }); |
| 1313 | + |
| 1314 | + it('Modify-All mints, with the visibility read refused (the bypass branch alone admits)', async () => { |
| 1315 | + await expect(engine.find('conversations', { where: { id: 'c1' }, context: as(ADMIN) })) |
| 1316 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); |
| 1317 | + const link = await service.createLink(conversation(), as(ADMIN)); |
| 1318 | + expect(link).toMatchObject({ record_id: 'c1', created_by: ADMIN }); |
| 1319 | + }); |
| 1320 | + |
| 1321 | + it('visibility still admits on its own, without asking the owner/bypass probe', async () => { |
| 1322 | + const link = await service.createLink(conversation(), as(READER)); |
| 1323 | + expect(link.created_by).toBe(READER); |
| 1324 | + expect(mintProbeCalls).toEqual([]); |
| 1325 | + }); |
| 1326 | + |
| 1327 | + it('an empty visibility read stays 403 FORBIDDEN for a caller no alternative admits', async () => { |
| 1328 | + // A row filter, not a CRUD refusal: the read answers nothing. |
| 1329 | + engine.find = (async (object: string, options?: any) => |
| 1330 | + options?.context?.isSystem === true ? engine._tables[object] ?? [] : []) as any; |
| 1331 | + await expect(service.createLink(conversation(), as(STRANGER))) |
| 1332 | + .rejects.toMatchObject({ status: 403, code: 'FORBIDDEN' }); |
| 1333 | + // ...and the owner of the same row mints past it. |
| 1334 | + await expect(service.createLink(conversation(), as(OWNER))).resolves.toMatchObject({ created_by: OWNER }); |
| 1335 | + }); |
| 1336 | + |
| 1337 | + describe('the order: opt-in, then authority, then eligibility', () => { |
| 1338 | + it('the opt-in comes first — the owner of a record on an object that never opted in is refused 422', async () => { |
| 1339 | + await expect(service.createLink(mintIn('notes', 'n1'), as(OWNER))) |
| 1340 | + .rejects.toMatchObject({ status: 422, code: 'SHARING_NOT_ENABLED' }); |
| 1341 | + expect(mintProbeCalls).toEqual([]); |
| 1342 | + }); |
| 1343 | + |
| 1344 | + it('eligibility is judged after the owner is admitted: an ineligible record is refused 422, an eligible one mints', async () => { |
| 1345 | + await expect(service.createLink(mintIn('briefs', 'b_draft'), as(OWNER))) |
| 1346 | + .rejects.toMatchObject({ status: 422, code: 'RECORD_NOT_ELIGIBLE' }); |
| 1347 | + await expect(service.createLink(mintIn('briefs', 'b_pub'), as(OWNER))) |
| 1348 | + .resolves.toMatchObject({ record_id: 'b_pub', created_by: OWNER }); |
| 1349 | + }); |
| 1350 | + |
| 1351 | + it('a caller with no authority is refused BEFORE eligibility, learning nothing about the record', async () => { |
| 1352 | + // The draft would draw RECORD_NOT_ELIGIBLE; the stranger never reaches it. |
| 1353 | + await expect(service.createLink(mintIn('briefs', 'b_draft'), as(STRANGER))) |
| 1354 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); |
| 1355 | + }); |
| 1356 | + }); |
| 1357 | + |
| 1358 | + describe('where the alternatives are withheld', () => { |
| 1359 | + it.each(['isolated', 'group'])( |
| 1360 | + 'an organization wall (%s): visibility alone admits, so the owner and Modify-All are refused', |
| 1361 | + async (walled) => { |
| 1362 | + posture = walled; |
| 1363 | + for (const who of [OWNER, ADMIN]) { |
| 1364 | + await expect(service.createLink(conversation(), as(who))) |
| 1365 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); |
| 1366 | + } |
| 1367 | + expect(minted()).toEqual([]); |
| 1368 | + // Revoke authority is untouched by the wall: the owner still manages the record. |
| 1369 | + expect(await sharing.canManageShares('conversations', 'c1', as(OWNER))).toBe(true); |
| 1370 | + }, |
| 1371 | + ); |
| 1372 | + |
| 1373 | + it('an unresolvable posture counts as walled (fail closed)', async () => { |
| 1374 | + posture = undefined; |
| 1375 | + await expect(service.createLink(conversation(), as(OWNER))) |
| 1376 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); |
| 1377 | + }); |
| 1378 | + |
| 1379 | + it('a deployment without the probe keeps visibility alone (the pre-A′ rule)', async () => { |
| 1380 | + const probeless = new ShareLinkService({ |
| 1381 | + engine: engine as any, |
| 1382 | + canManageShares: (o, r, c) => sharing.canManageShares(o, r, c), |
| 1383 | + }); |
| 1384 | + await expect(probeless.createLink(conversation(), as(OWNER))) |
| 1385 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); |
| 1386 | + }); |
| 1387 | + |
| 1388 | + it('a throwing probe is a refusal, answered with the visibility read\'s own envelope', async () => { |
| 1389 | + const broken = new ShareLinkService({ |
| 1390 | + engine: engine as any, |
| 1391 | + canMintWithoutVisibility: async () => { throw new Error('probe down'); }, |
| 1392 | + }); |
| 1393 | + await expect(broken.createLink(conversation(), as(OWNER))) |
| 1394 | + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); |
| 1395 | + expect(minted()).toEqual([]); |
| 1396 | + }); |
| 1397 | + }); |
| 1398 | +}); |
0 commit comments