|
3 | 3 | import { describe, it, expect } from 'vitest'; |
4 | 4 | import type { |
5 | 5 | HierarchyScopeContext, |
| 6 | + ISharingRuleService, |
6 | 7 | ISharingService, |
7 | 8 | RecordShareRecipientType, |
| 9 | + SharingExecutionContext, |
8 | 10 | SharingRuleRecipientType, |
9 | 11 | SharingWriteVerdict, |
10 | 12 | } from './sharing-service'; |
| 13 | +import type { IApprovalService } from './approval-service'; |
| 14 | +import type { IReportService } from './report-service'; |
| 15 | +import type { ExecutionContext } from '../kernel/execution-context.zod'; |
11 | 16 | import { ShareRecipientType } from '../security/sharing.zod'; |
12 | 17 |
|
| 18 | +/** Type-level identity: true iff A and B are the same type. */ |
| 19 | +type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) |
| 20 | + ? true |
| 21 | + : false; |
| 22 | +/** Compile error when the argument is not `true`. */ |
| 23 | +type Assert<T extends true> = T; |
| 24 | +/** Compile error when the argument is not `false`. */ |
| 25 | +type Refute<T extends false> = T; |
| 26 | + |
13 | 27 | /** |
14 | 28 | * [#4539] `RecordShareRecipientType` (né `ShareRecipientType`) pins. |
15 | 29 | * |
@@ -481,3 +495,151 @@ describe('[#6428] ISharingService tri-state write verdict', () => { |
481 | 495 | expect(docOf.get('buildReadFilter')).not.toContain('abstain'); |
482 | 496 | }); |
483 | 497 | }); |
| 498 | + |
| 499 | +/** |
| 500 | + * [#6523 / #6206 ruling default] The shared enforcement context is the FULL |
| 501 | + * envelope — the fourth and widest narrow twin, converged. |
| 502 | + * |
| 503 | + * ## What the ruling decided, and what this card applied it to |
| 504 | + * |
| 505 | + * #6206 (maintainer, 2026-08-07) set the governance default: enforcement |
| 506 | + * converges on the complete `resolveAuthzContext` envelope and keeps NO |
| 507 | + * per-site subset contracts. Its sweep reached one site — share-link (#6430 / |
| 508 | + * PR #6511). `SharingExecutionContext` was the fourth and by far the widest |
| 509 | + * twin: six declared fields serving **36 signatures across three contracts** |
| 510 | + * (`ISharingService` + `ISharingRuleService` here, `IApprovalService`, |
| 511 | + * `IReportService`), every one of them adjudicating access, with |
| 512 | + * `accessible_org_ids` / `org_user_ids` / `posture` / `tabPermissions` absent. |
| 513 | + * |
| 514 | + * ## The MIRROR direction — why this twin cost something different |
| 515 | + * |
| 516 | + * At the share-link site the caller trimmed the VALUE before enforcement saw |
| 517 | + * it. Here nothing was trimmed: `plugin-sharing`'s engine middleware hands the |
| 518 | + * whole execution context down (`buildReadFilter(ctx.object, exec ?? {})`), so |
| 519 | + * the values always arrived complete — it was the declared TYPE that was |
| 520 | + * narrow, so an implementation could not read what it had been given without |
| 521 | + * casting out of its own contract. The specimen on `main` when this card was |
| 522 | + * written, in `plugin-approvals`' privileged-override gate: |
| 523 | + * |
| 524 | + * const posture = (context as any).posture; // isOverrideActor() |
| 525 | + * |
| 526 | + * ## What is pinned here, and what deliberately is NOT |
| 527 | + * |
| 528 | + * PINNED: (1) the context parameter of every adjudicating method across the |
| 529 | + * three contracts is `ExecutionContext`, BY TYPE IDENTITY, so re-narrowing it |
| 530 | + * to anything — the old type included — goes red; (2) the SHAPE WITNESS: an |
| 531 | + * implementation typed by the contract reads `accessible_org_ids` / `posture` |
| 532 | + * / `org_user_ids` / `tabPermissions` with **no `as any`**, which under the old |
| 533 | + * signature was TS2339 on each field — that is this file's before-red |
| 534 | + * direction, and it is the mirror of PR #6511's, which was TS2353 at a call |
| 535 | + * site; (3) the narrow type survives UNCHANGED IN SHAPE, so the convergence |
| 536 | + * cannot be undone by widening it back one field at a time. |
| 537 | + * |
| 538 | + * NOT PINNED, on purpose, and for exactly the reason PR #6511 recorded: there |
| 539 | + * is no `@ts-expect-error` asserting that a `SharingExecutionContext` is |
| 540 | + * REJECTED where an `ExecutionContext` is expected, because it is not. |
| 541 | + * Structural subtyping accepts it — all six fields exist in the wider type |
| 542 | + * with compatible types, and nothing there is required. A pin shaped like |
| 543 | + * compiler enforcement where only a declaration exists would read as verified |
| 544 | + * and be worse than saying so. |
| 545 | + */ |
| 546 | +describe('[#6523] sharing / approval / report enforcement takes the full ExecutionContext', () => { |
| 547 | + it('declares the full envelope on every adjudicating signature, by type identity', () => { |
| 548 | + // Type-level assertions are the substance of this case; the runtime |
| 549 | + // expectation below only keeps vitest from reporting an empty test. tsc |
| 550 | + // compiles this file (tsconfig.test.json, #5286), so these are checked. |
| 551 | + type SharingCtx = Parameters<ISharingService['buildReadFilter']>[1]; |
| 552 | + type EditCtx = Parameters<ISharingService['checkEdit']>[2]; |
| 553 | + type GrantCtx = Parameters<ISharingService['grant']>[1]; |
| 554 | + type RuleCtx = Parameters<ISharingRuleService['evaluateRule']>[1]; |
| 555 | + type ApprovalCtx = Parameters<IApprovalService['decide']>[2]; |
| 556 | + type ReportCtx = Parameters<IReportService['run']>[1]; |
| 557 | + |
| 558 | + type _Pins = [ |
| 559 | + Assert<Eq<SharingCtx, ExecutionContext>>, |
| 560 | + Assert<Eq<EditCtx, ExecutionContext>>, |
| 561 | + Assert<Eq<GrantCtx, ExecutionContext>>, |
| 562 | + Assert<Eq<RuleCtx, ExecutionContext>>, |
| 563 | + Assert<Eq<ApprovalCtx, ExecutionContext>>, |
| 564 | + Assert<Eq<ReportCtx, ExecutionContext>>, |
| 565 | + // …and none of them is the six-field twin any more. |
| 566 | + Refute<Eq<SharingCtx, SharingExecutionContext>>, |
| 567 | + Refute<Eq<ApprovalCtx, SharingExecutionContext>>, |
| 568 | + Refute<Eq<ReportCtx, SharingExecutionContext>>, |
| 569 | + ]; |
| 570 | + const pinned: _Pins = [true, true, true, true, true, true, false, false, false]; |
| 571 | + expect(pinned).toHaveLength(9); |
| 572 | + }); |
| 573 | + |
| 574 | + it('lets an implementation READ the envelope it is handed — no `as any` (shape witness)', async () => { |
| 575 | + // The witness for the mirror direction. `context` is typed BY THE CONTRACT |
| 576 | + // — `Parameters<ISharingService['buildReadFilter']>[1]`, not by a local |
| 577 | + // annotation — so if the contract re-narrows, the four reads below stop |
| 578 | + // compiling (TS2339: "Property 'accessible_org_ids' does not exist on type |
| 579 | + // 'SharingExecutionContext'"). That is precisely the wall |
| 580 | + // `plugin-approvals` climbed with `(context as any).posture`. |
| 581 | + const seen: Array<Record<string, unknown>> = []; |
| 582 | + const buildReadFilter: ISharingService['buildReadFilter'] = async (object, context) => { |
| 583 | + seen.push({ |
| 584 | + object, |
| 585 | + // ADR-0105 D2 — under the `group` posture this set IS the Layer 0 wall. |
| 586 | + accessible_org_ids: context.accessible_org_ids, |
| 587 | + // ADR-0095 D2 — resolved once upstream and carried, never re-derived here. |
| 588 | + posture: context.posture, |
| 589 | + org_user_ids: context.org_user_ids, |
| 590 | + tabPermissions: context.tabPermissions, |
| 591 | + }); |
| 592 | + return null; |
| 593 | + }; |
| 594 | + |
| 595 | + // The call site, spelled the way `plugin-sharing`'s engine middleware |
| 596 | + // spells it: the whole resolved envelope, handed straight down. Written as |
| 597 | + // an object LITERAL on purpose — excess-property checking applies to |
| 598 | + // literals, so under the old parameter type each of the four keys below |
| 599 | + // was additionally a TS2353 error. |
| 600 | + const envelope: ExecutionContext = { |
| 601 | + userId: 'usr_1', |
| 602 | + tenantId: 'org_plant_a', |
| 603 | + positions: ['sales'], |
| 604 | + permissions: ['standard_user'], |
| 605 | + systemPermissions: ['manage_sharing'], |
| 606 | + accessible_org_ids: ['org_plant_a', 'org_plant_b'], |
| 607 | + org_user_ids: ['usr_1', 'usr_2'], |
| 608 | + posture: 'MEMBER', |
| 609 | + tabPermissions: { crm: 'visible' }, |
| 610 | + }; |
| 611 | + expect(await buildReadFilter('account', envelope)).toBeNull(); |
| 612 | + |
| 613 | + // Anti-vacuity: the values really travelled, and were really readable. |
| 614 | + expect(seen).toHaveLength(1); |
| 615 | + expect(seen[0]).toEqual({ |
| 616 | + object: 'account', |
| 617 | + accessible_org_ids: ['org_plant_a', 'org_plant_b'], |
| 618 | + posture: 'MEMBER', |
| 619 | + org_user_ids: ['usr_1', 'usr_2'], |
| 620 | + tabPermissions: { crm: 'visible' }, |
| 621 | + }); |
| 622 | + }); |
| 623 | + |
| 624 | + it('keeps the narrow twin unchanged in shape — it is residue, not a shortcut', () => { |
| 625 | + // Widening `SharingExecutionContext` instead of replacing it would rebuild |
| 626 | + // the per-site subset the ruling removed, one field at a time. PR #6511 |
| 627 | + // pinned the same refusal for the share-link twin. |
| 628 | + type NarrowKeys = keyof SharingExecutionContext; |
| 629 | + type _ShapeUnchanged = Assert< |
| 630 | + Eq<NarrowKeys, 'userId' | 'tenantId' | 'positions' | 'permissions' | 'systemPermissions' | 'isSystem'> |
| 631 | + >; |
| 632 | + const shapeUnchanged: _ShapeUnchanged = true; |
| 633 | + |
| 634 | + // The honest half, exactly as PR #6511 recorded it for its own twin: this |
| 635 | + // assignment is LEGAL and compiles. Six optional fields, all present in the |
| 636 | + // wider type — so the boundary is held by the declared parameter type and |
| 637 | + // the caller's obligation, never by tsc. An `@ts-expect-error` here would |
| 638 | + // be unsatisfied and fail the build. |
| 639 | + const residue: SharingExecutionContext = { userId: 'usr_1', isSystem: false }; |
| 640 | + const widened: ExecutionContext = residue; |
| 641 | + expect(shapeUnchanged).toBe(true); |
| 642 | + expect(widened.userId).toBe('usr_1'); |
| 643 | + expect(widened.accessible_org_ids).toBeUndefined(); |
| 644 | + }); |
| 645 | +}); |
0 commit comments