|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
| 3 | +import { readFileSync } from 'node:fs'; |
| 4 | +import { dirname, resolve } from 'node:path'; |
| 5 | +import { fileURLToPath } from 'node:url'; |
| 6 | + |
3 | 7 | import { describe, it, expect, vi } from 'vitest'; |
4 | 8 | import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; |
5 | 9 | import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; |
6 | 10 | import { CAPABILITY_NAME_COLLISION } from './capability-name-collision.js'; |
| 11 | +import { |
| 12 | + CAPABILITY_DECLARATION_UNOWNED, |
| 13 | + CAPABILITY_PLATFORM_NAME_REFUSED, |
| 14 | + CAPABILITY_ROWS_UNREADABLE, |
| 15 | +} from './seed-refusal-diagnostics.js'; |
| 16 | + |
| 17 | +/** [#18091] Seeded from this file, for the class pin at the bottom. */ |
| 18 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
7 | 19 |
|
8 | 20 | /** Minimal in-memory ql for sys_capability seeding with a registry stub. */ |
9 | 21 | function makeQl(declared: any[] = []) { |
@@ -705,3 +717,201 @@ describe('[#18023] a capability-name collision reaches the author', () => { |
705 | 717 | expect(out.collisions).toBeUndefined(); |
706 | 718 | }); |
707 | 719 | }); |
| 720 | + |
| 721 | +// ─────────────────────────────────────────────────────────────────────────── |
| 722 | +// [#18091] The three refusals #18023 left behind in this seeder. Same defect, |
| 723 | +// same reading: driven with NO logger the author-visible line count was 0 at |
| 724 | +// every one of them while the already-repaired collision path in the harness |
| 725 | +// above read 1. Each case below asserts THAT SITE'S OWN sentence, so a future |
| 726 | +// regression to one generic "declaration skipped" line is caught by name. |
| 727 | +// ─────────────────────────────────────────────────────────────────────────── |
| 728 | + |
| 729 | +/** `makeQl` with a read that cannot answer — the `unreadable` branch's only entry. */ |
| 730 | +function unreadableQl(declared: any[]) { |
| 731 | + const ql = makeQl(declared); |
| 732 | + (ql as any).find = async () => { throw new Error('sys_capability is unreachable'); }; |
| 733 | + return ql; |
| 734 | +} |
| 735 | + |
| 736 | +describe('[#18091] the three remaining capability refusals reach the author', () => { |
| 737 | + it('CURATED PLATFORM NAME: prints with NO LOGGER INJECTED, and names what is lost', async () => { |
| 738 | + const ql = makeQl([{ name: 'manage_users', label: 'Evil', description: 'Hijack.', _packageId: 'com.acme.evil' }]); |
| 739 | + |
| 740 | + const cap = captureAllConsole(); |
| 741 | + let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>; |
| 742 | + try { |
| 743 | + out = await bootstrapDeclaredCapabilities(ql, null, { |
| 744 | + permissionSets: [{ name: 'acme_ops', systemPermissions: ['manage_users'] }], |
| 745 | + }); |
| 746 | + } finally { |
| 747 | + cap.restore(); |
| 748 | + } |
| 749 | + |
| 750 | + // ── The reading this card moves: 0 → 1 ────────────────────────────────── |
| 751 | + expect(out.skippedPlatform).toBe(1); |
| 752 | + expect(cap.seen).toHaveLength(1); |
| 753 | + expect(cap.seen[0]!.startsWith('warn: ')).toBe(true); |
| 754 | + expect(cap.seen[0]).toContain(CAPABILITY_PLATFORM_NAME_REFUSED); |
| 755 | + expect(cap.seen[0]).toContain('manage_users'); |
| 756 | + expect(cap.seen[0]).toContain('com.acme.evil'); |
| 757 | + // ── THIS site's consequence, not the foreign-owner one. The curated row |
| 758 | + // answers for the name, so nothing is denied — an author sent hunting |
| 759 | + // for a broken grant is the failure this wording prevents. |
| 760 | + expect(cap.seen[0]).toContain('CURATED PLATFORM capability'); |
| 761 | + expect(cap.seen[0]).toContain('The name still resolves'); |
| 762 | + expect(cap.seen[0]).toContain('acme_ops'); |
| 763 | + // ⛔ And the remedy is rename-only: a curated name is not co-ownable, so |
| 764 | + // the ADR-0130 D1 escape the collision diagnostic offers must NOT appear. |
| 765 | + expect(cap.seen[0]).toContain('not co-ownable'); |
| 766 | + |
| 767 | + // ── ⛔ The refusal itself is UNCHANGED ─────────────────────────────────── |
| 768 | + expect(ql.rows.find((r) => r.name === 'manage_users')).toBeUndefined(); |
| 769 | + expect(out.seeded).toBe(0); |
| 770 | + // …and the name is still reported materialized: the curated pass owns it. |
| 771 | + expect(out.materializedNames).toEqual(['manage_users']); |
| 772 | + }); |
| 773 | + |
| 774 | + it('UNOWNED DECLARATION: prints with NO LOGGER INJECTED, keeping its three-way consequence', async () => { |
| 775 | + const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]); |
| 776 | + |
| 777 | + const cap = captureAllConsole(); |
| 778 | + let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>; |
| 779 | + try { |
| 780 | + out = await bootstrapDeclaredCapabilities(ql, null, { |
| 781 | + permissionSets: [{ name: 'showcase_ops', systemPermissions: ['showcase.export_data'] }], |
| 782 | + }); |
| 783 | + } finally { |
| 784 | + cap.restore(); |
| 785 | + } |
| 786 | + |
| 787 | + expect(out.skippedUnowned).toBe(1); |
| 788 | + expect(cap.seen).toHaveLength(1); |
| 789 | + expect(cap.seen[0]!.startsWith('warn: ')).toBe(true); |
| 790 | + expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED); |
| 791 | + expect(cap.seen[0]).toContain('has no owning package'); |
| 792 | + // [#4967 Part 3] The grantor and the ACTUAL consequence, both preserved — |
| 793 | + // this arm is "a row will be derived", which is not the other two arms. |
| 794 | + expect(cap.seen[0]).toContain('showcase_ops'); |
| 795 | + expect(cap.seen[0]).toContain('derived placeholder'); |
| 796 | + expect(cap.seen[0]).not.toContain('materialized nowhere'); |
| 797 | + // ⛔ Unchanged: no row is written for an unowned declaration. |
| 798 | + expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toBeUndefined(); |
| 799 | + }); |
| 800 | + |
| 801 | + it('UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence', async () => { |
| 802 | + const ql = unreadableQl([ |
| 803 | + { name: 'a.one', _packageId: 'com.a' }, |
| 804 | + { name: 'a.two', _packageId: 'com.a' }, |
| 805 | + ]); |
| 806 | + |
| 807 | + const cap = captureAllConsole(); |
| 808 | + let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>; |
| 809 | + try { |
| 810 | + out = await bootstrapDeclaredCapabilities(ql, null); |
| 811 | + } finally { |
| 812 | + cap.restore(); |
| 813 | + } |
| 814 | + |
| 815 | + expect(out.unreadable).toBe(2); |
| 816 | + expect(cap.seen).toHaveLength(1); |
| 817 | + expect(cap.seen[0]!.startsWith('warn: ')).toBe(true); |
| 818 | + expect(cap.seen[0]).toContain(CAPABILITY_ROWS_UNREADABLE); |
| 819 | + // The count, and the consequence "unreadable" alone does not state. |
| 820 | + expect(cap.seen[0]).toContain('2 of 2'); |
| 821 | + expect(cap.seen[0]).toContain('keeps the stale value'); |
| 822 | + expect(cap.seen[0]).toContain('nothing is lost'); |
| 823 | + // ⛔ Unchanged: a name whose row could not be read is left ENTIRELY alone, |
| 824 | + // and stays out of `materializedNames` so the derivation gets its attempt. |
| 825 | + expect(ql.rows).toHaveLength(0); |
| 826 | + expect(out.materializedNames).toEqual([]); |
| 827 | + }); |
| 828 | + |
| 829 | + it('⭐ each site keeps its OWN sentence — ⛔ never one generic refusal line', async () => { |
| 830 | + // The discriminating case for R3: two different refusals in ONE pass. |
| 831 | + const ql = makeQl([ |
| 832 | + { name: 'manage_users', _packageId: 'com.acme.evil' }, |
| 833 | + { name: 'orphan_cap' }, |
| 834 | + ]); |
| 835 | + |
| 836 | + const cap = captureAllConsole(); |
| 837 | + try { |
| 838 | + await bootstrapDeclaredCapabilities(ql, null); |
| 839 | + } finally { |
| 840 | + cap.restore(); |
| 841 | + } |
| 842 | + |
| 843 | + expect(cap.seen).toHaveLength(2); |
| 844 | + const curated = cap.seen.find((l) => l.includes(CAPABILITY_PLATFORM_NAME_REFUSED)); |
| 845 | + const unowned = cap.seen.find((l) => l.includes(CAPABILITY_DECLARATION_UNOWNED)); |
| 846 | + expect(curated).toBeDefined(); |
| 847 | + expect(unowned).toBeDefined(); |
| 848 | + // ⛔ Two tokens, two consequences. A generic sentence would make these two |
| 849 | + // assertions pass against ONE wording, so each names a phrase only its own |
| 850 | + // site can produce. |
| 851 | + expect(curated).toContain('The name still resolves'); |
| 852 | + expect(curated).not.toContain('derived placeholder'); |
| 853 | + expect(unowned).toContain('materialized nowhere'); |
| 854 | + expect(unowned).not.toContain('CURATED PLATFORM capability'); |
| 855 | + }); |
| 856 | + |
| 857 | + it('an INJECTED logger takes all three, and the console stays clean', async () => { |
| 858 | + const warn = vi.fn(); |
| 859 | + const cap = captureAllConsole(); |
| 860 | + try { |
| 861 | + await bootstrapDeclaredCapabilities(makeQl([{ name: 'manage_users', _packageId: 'com.a' }]), null, { logger: { warn } }); |
| 862 | + await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: { warn } }); |
| 863 | + await bootstrapDeclaredCapabilities(unreadableQl([{ name: 'a.one', _packageId: 'com.a' }]), null, { logger: { warn } }); |
| 864 | + } finally { |
| 865 | + cap.restore(); |
| 866 | + } |
| 867 | + |
| 868 | + // ⚠️ FOUR, not three: the third pass ALSO trips the batched existence |
| 869 | + // oracle's own read-failure line, which is a different diagnostic in a |
| 870 | + // different module (`seed-name-lookup.ts`) and outside this card. Pinning |
| 871 | + // three here would have made that line's removal invisible; filtering by |
| 872 | + // this card's own tokens keeps the assertion about this card. |
| 873 | + expect(warn).toHaveBeenCalledTimes(4); |
| 874 | + const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); |
| 875 | + expect(events).toEqual([ |
| 876 | + CAPABILITY_PLATFORM_NAME_REFUSED, |
| 877 | + CAPABILITY_DECLARATION_UNOWNED, |
| 878 | + CAPABILITY_ROWS_UNREADABLE, |
| 879 | + ]); |
| 880 | + expect(cap.seen).toEqual([]); |
| 881 | + }); |
| 882 | + |
| 883 | + it('a HOST SINK THAT LIES about its shape is reported to the console, never thrown at', async () => { |
| 884 | + // ⚠️ `ProjectionLogger.warn` is non-optional, but the type cannot reach a |
| 885 | + // plain-JS embedder or a cast. The old `logger?.warn?.()` bought safety here |
| 886 | + // with silence; `if (logger) logger.warn()` would buy noise with a throw |
| 887 | + // inside a seeding pass. The `typeof` guard buys neither. |
| 888 | + const liar = { info: () => {} } as any; |
| 889 | + const cap = captureAllConsole(); |
| 890 | + let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>; |
| 891 | + try { |
| 892 | + out = await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: liar }); |
| 893 | + } finally { |
| 894 | + cap.restore(); |
| 895 | + } |
| 896 | + expect(out.skippedUnowned).toBe(1); |
| 897 | + expect(cap.seen).toHaveLength(1); |
| 898 | + expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED); |
| 899 | + }); |
| 900 | + |
| 901 | + it('⛔ CLASS PIN: the doubly-optional warn survives in this seeder only as PROSE', async () => { |
| 902 | + // The reason this card exists: the shape was repaired one instance at a |
| 903 | + // time twice before. A grep that reds when a sixth call site appears is the |
| 904 | + // difference between a third instance repair and a class that is closed. |
| 905 | + const source = readFileSync(resolve(HERE, 'bootstrap-declared-capabilities.ts'), 'utf8'); |
| 906 | + // Positive control — the pin is reading the file it thinks it is. |
| 907 | + expect(source).toContain('export async function bootstrapDeclaredCapabilities'); |
| 908 | + const hits = source.split('\n').filter((line) => line.includes('logger?.warn?.(')); |
| 909 | + for (const line of hits) { |
| 910 | + expect(line.trimStart().startsWith('//') || line.trimStart().startsWith('*')).toBe(true); |
| 911 | + } |
| 912 | + // ⚠️ The INFO channel keeps its outer `?.` deliberately and is NOT part of |
| 913 | + // this class: a pass that refused nothing must stay silent on every console |
| 914 | + // channel with no sink, which is the control above. |
| 915 | + expect(source).toContain("options.logger?.info?.("); |
| 916 | + }); |
| 917 | +}); |
0 commit comments