|
| 1 | +import { describe, expect, it, vi } from 'vitest'; |
| 2 | + |
| 3 | +import * as barrel from '../../src/index'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Enforces the cross-bundle branding participation criterion from |
| 7 | + * `core-internal/src/errors/crossBundleBrand.ts`: every error class exported |
| 8 | + * from this package's public surface must carry an own `mcpBrand` static and |
| 9 | + * resolve `instanceof` through a branded `Symbol.hasInstance`. |
| 10 | + * |
| 11 | + * This is the infrastructure that replaces "remember the static block": adding |
| 12 | + * a new exported error class without branding turns this test red naming the |
| 13 | + * class, instead of shipping a subclass whose cross-bundle `instanceof` |
| 14 | + * silently returns false while its parent still matches. |
| 15 | + */ |
| 16 | + |
| 17 | +/** Error classes exported on purpose without a brand. Justify every entry. */ |
| 18 | +const UNBRANDED_ALLOWLIST: ReadonlySet<string> = new Set([]); |
| 19 | + |
| 20 | +function exportedErrorClasses(mod: Record<string, unknown>): Array<[string, Function]> { |
| 21 | + return Object.entries(mod) |
| 22 | + .filter((entry): entry is [string, Function] => { |
| 23 | + const v = entry[1]; |
| 24 | + return typeof v === 'function' && v !== Error && v.prototype instanceof Error; |
| 25 | + }) |
| 26 | + .sort(([a], [b]) => a.localeCompare(b)); |
| 27 | +} |
| 28 | + |
| 29 | +describe('error brand conformance (client export surface)', () => { |
| 30 | + const classes = exportedErrorClasses(barrel as Record<string, unknown>); |
| 31 | + |
| 32 | + it('finds the export surface (guards against a broken walker)', () => { |
| 33 | + expect(classes.length).toBeGreaterThanOrEqual(15); |
| 34 | + expect(classes.map(([n]) => n)).toContain('ProtocolError'); |
| 35 | + expect(classes.map(([n]) => n)).toContain('OAuthClientFlowError'); |
| 36 | + }); |
| 37 | + |
| 38 | + it('every exported error class is branded (or explicitly allowlisted)', () => { |
| 39 | + const unbranded = classes |
| 40 | + .filter(([name]) => !UNBRANDED_ALLOWLIST.has(name)) |
| 41 | + .filter(([, cls]) => !Object.prototype.hasOwnProperty.call(cls, 'mcpBrand')) |
| 42 | + .map(([name]) => name); |
| 43 | + expect( |
| 44 | + unbranded, |
| 45 | + `unbranded exported error classes (add the static mcpBrand block, or allowlist with a justification): ${unbranded.join(', ')}` |
| 46 | + ).toEqual([]); |
| 47 | + }); |
| 48 | + |
| 49 | + it('every branded class resolves instanceof through a branded hasInstance', () => { |
| 50 | + const missing = classes |
| 51 | + .filter(([, cls]) => Object.prototype.hasOwnProperty.call(cls, 'mcpBrand')) |
| 52 | + .filter(([, cls]) => (cls as never as Record<symbol, unknown>)[Symbol.hasInstance] === Function.prototype[Symbol.hasInstance]) |
| 53 | + .map(([name]) => name); |
| 54 | + expect(missing, `branded classes whose hierarchy root does not install brandedHasInstance: ${missing.join(', ')}`).toEqual([]); |
| 55 | + }); |
| 56 | + |
| 57 | + // Core-internal brand strings are pinned once, in core-internal's |
| 58 | + // errorSurfacePins.test.ts — this test only asserts the core re-export SET |
| 59 | + // so a rename there is not double-maintained here. |
| 60 | + const CORE_PINNED = new Set([ |
| 61 | + 'MissingRequiredClientCapabilityError', |
| 62 | + 'OAuthError', |
| 63 | + 'ProtocolError', |
| 64 | + 'ResourceNotFoundError', |
| 65 | + 'SdkError', |
| 66 | + 'SdkHttpError', |
| 67 | + 'UnsupportedProtocolVersionError', |
| 68 | + 'UrlElicitationRequiredError' |
| 69 | + ]); |
| 70 | + |
| 71 | + it('pins every client-local brand string (renaming one severs cross-version matching — must be deliberate)', () => { |
| 72 | + const brands = Object.fromEntries( |
| 73 | + classes |
| 74 | + .filter(([name, cls]) => !CORE_PINNED.has(name) && Object.prototype.hasOwnProperty.call(cls, 'mcpBrand')) |
| 75 | + .map(([name, cls]) => [name, (cls as never as { mcpBrand: string }).mcpBrand]) |
| 76 | + ); |
| 77 | + expect(brands).toEqual({ |
| 78 | + AuthorizationServerMismatchError: 'mcp.AuthorizationServerMismatchError', |
| 79 | + InsecureTokenEndpointError: 'mcp.InsecureTokenEndpointError', |
| 80 | + InsufficientScopeError: 'mcp.InsufficientScopeError', |
| 81 | + IssuerMismatchError: 'mcp.IssuerMismatchError', |
| 82 | + OAuthClientFlowError: 'mcp.OAuthClientFlowError', |
| 83 | + RegistrationRejectedError: 'mcp.RegistrationRejectedError', |
| 84 | + SseError: 'mcp.SseError', |
| 85 | + UnauthorizedError: 'mcp.UnauthorizedError' |
| 86 | + }); |
| 87 | + }); |
| 88 | + |
| 89 | + it('core re-exported error classes are exactly the set pinned in errorSurfacePins.test.ts', () => { |
| 90 | + const coreExported = classes.map(([name]) => name).filter(name => CORE_PINNED.has(name)); |
| 91 | + expect(coreExported).toEqual([...CORE_PINNED].sort((a, b) => a.localeCompare(b))); |
| 92 | + }); |
| 93 | + |
| 94 | + it('the OAuth flow family and UnauthorizedError match across module copies', async () => { |
| 95 | + vi.resetModules(); |
| 96 | + const foreignAuthErrors = await import('../../src/client/authErrors'); |
| 97 | + const foreignAuth = await import('../../src/client/auth'); |
| 98 | + // Guard the premise: a cached module would make every check below pass |
| 99 | + // trivially through prototype identity. |
| 100 | + expect(foreignAuthErrors.IssuerMismatchError).not.toBe(barrel.IssuerMismatchError); |
| 101 | + expect(foreignAuth.UnauthorizedError).not.toBe(barrel.UnauthorizedError); |
| 102 | + |
| 103 | + const issuer = new foreignAuthErrors.IssuerMismatchError('metadata', 'a', 'b'); |
| 104 | + expect(issuer instanceof barrel.IssuerMismatchError).toBe(true); |
| 105 | + expect(issuer instanceof barrel.OAuthClientFlowError).toBe(true); |
| 106 | + expect(issuer instanceof barrel.UnauthorizedError).toBe(false); |
| 107 | + |
| 108 | + const unauthorized = new foreignAuth.UnauthorizedError('nope'); |
| 109 | + expect(unauthorized instanceof barrel.UnauthorizedError).toBe(true); |
| 110 | + expect(unauthorized.name).toBe('UnauthorizedError'); |
| 111 | + }); |
| 112 | +}); |
0 commit comments