|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24243, ADR-0112] Every `new APIError(` this package throws carries a `code`. |
| 5 | + * |
| 6 | + * better-auth serialises an `APIError` body as it is given. A body without a |
| 7 | + * `code` reaches the caller as `message` alone, so a client (the console among |
| 8 | + * them) can only show or pattern-match English prose, on doors where |
| 9 | + * better-auth's own refusals carry codes. Eight throws in `auth-manager.ts` |
| 10 | + * shipped that way, found by hand; this pin is the enumeration, so the next one |
| 11 | + * cannot ship without a code. It is a test in this package, not a CI gate. |
| 12 | + * |
| 13 | + * It PARSES every non-test source file under `src/` and finds every |
| 14 | + * `new APIError(...)`. A site passes when its body carries the code in one of |
| 15 | + * two shapes, and is reported with its file and line otherwise: |
| 16 | + * |
| 17 | + * 1. an object literal with a top-level `code` property; or |
| 18 | + * 2. a body destructured, in the same function, from the result of |
| 19 | + * `judgeHeldSession(...)`: the held-session judge's ADR-0112 envelope, |
| 20 | + * whose code sits under `error` (`platform-admin-gate.held-session.test.ts` |
| 21 | + * pins that body's shape). |
| 22 | + * |
| 23 | + * No body, a literal without `code`, a spread, or an identifier from anywhere |
| 24 | + * else is a site this pin cannot read a code from, so it fails. |
| 25 | + * |
| 26 | + * It parses and does not type-check, so the code's VALUE is not judged here. |
| 27 | + * Each stamp is a constant typed `RegisteredErrorCode` or `StandardErrorCode`, |
| 28 | + * so `tsc` judges the value, and `check:error-code-provenance` judges whether a |
| 29 | + * registered one is listed under this package. |
| 30 | + * |
| 31 | + * Two controls keep the walk honest: |
| 32 | + * - a textual count over the comment-free source (the TypeScript printer, with |
| 33 | + * comments removed) must equal the sites the walk found in each file, so a |
| 34 | + * constructor spelling the walk does not recognise cannot slip past it; |
| 35 | + * - the classifier must report each shape that shipped without a code and |
| 36 | + * accept each shape that carries one (the fixture battery below). |
| 37 | + */ |
| 38 | + |
| 39 | +import { describe, it, expect } from 'vitest'; |
| 40 | +import { readdirSync, readFileSync } from 'node:fs'; |
| 41 | +import { dirname, join, relative } from 'node:path'; |
| 42 | +import { fileURLToPath } from 'node:url'; |
| 43 | +import ts from 'typescript'; |
| 44 | + |
| 45 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 46 | + |
| 47 | +interface ApiErrorSite { |
| 48 | + readonly file: string; |
| 49 | + readonly line: number; |
| 50 | + /** `null` when the body carries a code; otherwise why this pin cannot read one. */ |
| 51 | + readonly missing: string | null; |
| 52 | +} |
| 53 | + |
| 54 | +const isTestFile = (name: string): boolean => /\.(test|spec)\.tsx?$/.test(name) || name.endsWith('.d.ts'); |
| 55 | + |
| 56 | +function sourceFiles(dir: string): string[] { |
| 57 | + const out: string[] = []; |
| 58 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 59 | + const path = join(dir, entry.name); |
| 60 | + if (entry.isDirectory()) out.push(...sourceFiles(path)); |
| 61 | + else if (/\.tsx?$/.test(entry.name) && !isTestFile(entry.name)) out.push(path); |
| 62 | + } |
| 63 | + return out.sort(); |
| 64 | +} |
| 65 | + |
| 66 | +const isApiErrorCallee = (callee: ts.Expression): boolean => |
| 67 | + (ts.isIdentifier(callee) && callee.text === 'APIError') || |
| 68 | + (ts.isPropertyAccessExpression(callee) && callee.name.text === 'APIError'); |
| 69 | + |
| 70 | +function propertyName(name: ts.PropertyName): string | undefined { |
| 71 | + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text; |
| 72 | + return undefined; |
| 73 | +} |
| 74 | + |
| 75 | +function enclosingFunction(node: ts.Node): ts.Node | undefined { |
| 76 | + for (let cur = node.parent; cur; cur = cur.parent) { |
| 77 | + if (ts.isFunctionLike(cur)) return cur; |
| 78 | + } |
| 79 | + return undefined; |
| 80 | +} |
| 81 | + |
| 82 | +/** Every variable declaration inside `scope`, nested blocks included. */ |
| 83 | +function declarationsIn(scope: ts.Node): ts.VariableDeclaration[] { |
| 84 | + const found: ts.VariableDeclaration[] = []; |
| 85 | + const visit = (node: ts.Node): void => { |
| 86 | + if (ts.isVariableDeclaration(node)) found.push(node); |
| 87 | + ts.forEachChild(node, visit); |
| 88 | + }; |
| 89 | + visit(scope); |
| 90 | + return found; |
| 91 | +} |
| 92 | + |
| 93 | +const unwrapAwait = (e: ts.Expression | undefined): ts.Expression | undefined => |
| 94 | + e && ts.isAwaitExpression(e) ? e.expression : e; |
| 95 | + |
| 96 | +/** |
| 97 | + * Shape 2: `const { body } = held;` (or `{ body: X }`) where `held` is |
| 98 | + * `judgeHeldSession(…)`'s result, both in the throw's own function. |
| 99 | + */ |
| 100 | +function isHeldSessionBody(body: ts.Identifier, site: ts.NewExpression): boolean { |
| 101 | + const scope = enclosingFunction(site); |
| 102 | + if (!scope) return false; |
| 103 | + const decls = declarationsIn(scope); |
| 104 | + const holders = decls |
| 105 | + .filter((d) => ts.isObjectBindingPattern(d.name) && d.initializer && ts.isIdentifier(d.initializer)) |
| 106 | + .filter((d) => |
| 107 | + (d.name as ts.ObjectBindingPattern).elements.some((el) => { |
| 108 | + const key = el.propertyName ? propertyName(el.propertyName) : ts.isIdentifier(el.name) ? el.name.text : undefined; |
| 109 | + return key === 'body' && ts.isIdentifier(el.name) && el.name.text === body.text; |
| 110 | + }), |
| 111 | + ) |
| 112 | + .map((d) => (d.initializer as ts.Identifier).text); |
| 113 | + return holders.some((holder) => |
| 114 | + decls.some((d) => { |
| 115 | + if (!ts.isIdentifier(d.name) || d.name.text !== holder) return false; |
| 116 | + const init = unwrapAwait(d.initializer); |
| 117 | + return ( |
| 118 | + !!init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === 'judgeHeldSession' |
| 119 | + ); |
| 120 | + }), |
| 121 | + ); |
| 122 | +} |
| 123 | + |
| 124 | +function whyCodeless(site: ts.NewExpression, sf: ts.SourceFile): string | null { |
| 125 | + const body = site.arguments?.[1]; |
| 126 | + if (!body) return 'it passes no body at all'; |
| 127 | + if (ts.isObjectLiteralExpression(body)) { |
| 128 | + const carriesCode = body.properties.some( |
| 129 | + (p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'code', |
| 130 | + ); |
| 131 | + return carriesCode ? null : 'its body has no top-level `code` property'; |
| 132 | + } |
| 133 | + if (ts.isIdentifier(body) && isHeldSessionBody(body, site)) return null; |
| 134 | + return `its body is \`${body.getText(sf)}\`, which this pin cannot read a code from`; |
| 135 | +} |
| 136 | + |
| 137 | +/** Every `new APIError(…)` in one source text, each with its verdict. */ |
| 138 | +function apiErrorSites(file: string, text: string): ApiErrorSite[] { |
| 139 | + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); |
| 140 | + const sites: ApiErrorSite[] = []; |
| 141 | + const visit = (node: ts.Node): void => { |
| 142 | + if (ts.isNewExpression(node) && isApiErrorCallee(node.expression)) { |
| 143 | + sites.push({ |
| 144 | + file, |
| 145 | + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, |
| 146 | + missing: whyCodeless(node, sf), |
| 147 | + }); |
| 148 | + } |
| 149 | + ts.forEachChild(node, visit); |
| 150 | + }; |
| 151 | + visit(sf); |
| 152 | + return sites; |
| 153 | +} |
| 154 | + |
| 155 | +/** The textual control: constructor calls counted in the source with its comments removed. */ |
| 156 | +function textualCount(file: string, text: string): number { |
| 157 | + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); |
| 158 | + const printed = ts.createPrinter({ removeComments: true }).printFile(sf); |
| 159 | + return printed.match(/\bnew\s+(?:[A-Za-z_$][\w$]*\s*\.\s*)*APIError\s*\(/g)?.length ?? 0; |
| 160 | +} |
| 161 | + |
| 162 | +describe('every new APIError( in plugin-auth/src carries an ADR-0112 code (#24243)', () => { |
| 163 | + const files = sourceFiles(HERE); |
| 164 | + const perFile = files.map((path) => { |
| 165 | + const text = readFileSync(path, 'utf8'); |
| 166 | + const file = relative(HERE, path); |
| 167 | + return { file, sites: apiErrorSites(file, text), textual: textualCount(file, text) }; |
| 168 | + }); |
| 169 | + const sites = perFile.flatMap((f) => f.sites); |
| 170 | + |
| 171 | + it('the walk reaches the throws: a floor of sites and files, so a walk that reads nothing cannot pass', () => { |
| 172 | + // Measured when this pin landed: 30 sites in 4 files (`auth-manager.ts` |
| 173 | + // 24, `objectql-adapter.ts` 3, `impersonation-bearer-rotation.ts` 2, |
| 174 | + // `admin-impersonate-endpoint.ts` 1). A floor, not an exact count, so a new |
| 175 | + // throw does not have to edit this line; only losing them does. |
| 176 | + expect(sites.length).toBeGreaterThanOrEqual(30); |
| 177 | + expect(new Set(sites.map((s) => s.file)).size).toBeGreaterThanOrEqual(4); |
| 178 | + expect(perFile.find((f) => f.file === 'auth-manager.ts')?.sites.length ?? 0).toBeGreaterThanOrEqual(24); |
| 179 | + }); |
| 180 | + |
| 181 | + it('the textual control agrees with the walk in every file', () => { |
| 182 | + const disagreements = perFile |
| 183 | + .filter((f) => f.textual !== f.sites.length) |
| 184 | + .map((f) => `${f.file}: ${f.textual} textual, ${f.sites.length} walked`); |
| 185 | + expect(disagreements).toEqual([]); |
| 186 | + }); |
| 187 | + |
| 188 | + it('no site throws a body without a code', () => { |
| 189 | + const codeless = sites.filter((s) => s.missing !== null).map((s) => `${s.file}:${s.line}: ${s.missing}`); |
| 190 | + expect(codeless).toEqual([]); |
| 191 | + }); |
| 192 | +}); |
| 193 | + |
| 194 | +describe('the classifier, against the shapes it must tell apart', () => { |
| 195 | + const verdicts = (body: string) => apiErrorSites('fixture.ts', body).map((s) => s.missing === null); |
| 196 | + |
| 197 | + it('reports both shapes the eight code-less throws had (the card enumeration)', () => { |
| 198 | + // As they stood before #24243: a status read off a constant with a |
| 199 | + // message-only body (the SMS quota wall), and a status literal with a |
| 200 | + // message-only body (the other seven). |
| 201 | + const fixture = ` |
| 202 | + async function quota(message: string) { return new APIError(SMS_QUOTA_EXCEEDED_CODE, { message }); } |
| 203 | + async function hook() { |
| 204 | + throw new APIError('FORBIDDEN', { message: 'Creating additional organizations is disabled.' }); |
| 205 | + } |
| 206 | + async function relayed(err: any) { |
| 207 | + throw new APIError('FORBIDDEN', { message: err?.message ?? 'outside your scope' }); |
| 208 | + }`; |
| 209 | + expect(verdicts(fixture)).toEqual([false, false, false]); |
| 210 | + }); |
| 211 | + |
| 212 | + it('reports a site with no body, a spread with no code, and an identifier it cannot trace', () => { |
| 213 | + const fixture = ` |
| 214 | + function a() { throw new APIError('FORBIDDEN'); } |
| 215 | + function b(base: object) { throw new APIError('FORBIDDEN', { ...base }); } |
| 216 | + function c(body: object) { throw new APIError('FORBIDDEN', body); } |
| 217 | + function d() { const { body } = somethingElse(); throw new APIError('FORBIDDEN', body); }`; |
| 218 | + expect(verdicts(fixture)).toEqual([false, false, false, false]); |
| 219 | + }); |
| 220 | + |
| 221 | + it('accepts a literal or shorthand `code`, a string key, a member callee and the held-session relay', () => { |
| 222 | + const fixture = ` |
| 223 | + function a() { throw new APIError('FORBIDDEN', { message: 'm', code: SOME_CODE }); } |
| 224 | + function b(code: string) { throw new APIError('FORBIDDEN', { message: 'm', code }); } |
| 225 | + function c() { throw new APIError('FORBIDDEN', { 'code': 'X', message: 'm' }); } |
| 226 | + function d() { throw new api.APIError('FORBIDDEN', { message: 'm', code: SOME_CODE }); } |
| 227 | + async function e() { |
| 228 | + const held = judgeHeldSession({ authGate: await gateOf() }); |
| 229 | + if (!held) return; |
| 230 | + const { body } = held; |
| 231 | + throw new APIError('FORBIDDEN', body); |
| 232 | + }`; |
| 233 | + expect(verdicts(fixture)).toEqual([true, true, true, true, true]); |
| 234 | + }); |
| 235 | + |
| 236 | + it('the textual control sees past comments, and counts a member-callee site', () => { |
| 237 | + const fixture = ` |
| 238 | + // new APIError('FORBIDDEN', { message: 'in a comment' }) |
| 239 | + /** new APIError('X') in a doc block */ |
| 240 | + function a() { throw new api.APIError('FORBIDDEN', { message: 'm', code: C }); }`; |
| 241 | + expect(textualCount('fixture.ts', fixture)).toBe(1); |
| 242 | + expect(apiErrorSites('fixture.ts', fixture)).toHaveLength(1); |
| 243 | + }); |
| 244 | +}); |
0 commit comments