// tests/kb-update/test-auth-mode.test.mjs // Unit tests for scripts/kb-update/lib/auth-mode.mjs // Note: Test fixture credential values are deliberately short (<8 chars) to // stay below the secrets-scanner heuristic. They are stub markers, not keys. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { detectAuthMode, validateAuthForCron, readClaudeJson, } from '../../scripts/kb-update/lib/auth-mode.mjs'; function withTmp(fn) { const dir = mkdtempSync(join(tmpdir(), 'auth-test-')); try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } function makeStubRunner(exitCode) { const calls = []; const runner = (cmd, args) => { calls.push({ cmd, args }); return exitCode; }; return { runner, calls }; } const MISSING_PATH = '/__definitely__/__not__/__a__/__path__/.claude.json'; test('detectAuthMode — ANTHROPIC_API_KEY set → api-key', () => { const { runner } = makeStubRunner(0); const mode = detectAuthMode({ env: { ANTHROPIC_API_KEY: 'fake' }, runner, claudeJsonPath: MISSING_PATH, }); assert.equal(mode, 'api-key'); }); test('detectAuthMode — empty ANTHROPIC_API_KEY is ignored', () => { const { runner } = makeStubRunner(1); const mode = detectAuthMode({ env: { ANTHROPIC_API_KEY: ' ' }, runner, claudeJsonPath: MISSING_PATH, }); assert.equal(mode, 'unauthenticated'); }); test('detectAuthMode — CLAUDE_CODE_OAUTH set → long-oauth', () => { const { runner } = makeStubRunner(0); const mode = detectAuthMode({ env: { CLAUDE_CODE_OAUTH_TOKEN: 'oat' }, runner, claudeJsonPath: MISSING_PATH, }); assert.equal(mode, 'long-oauth'); }); test('detectAuthMode — both env vars set → api-key precedence', () => { const { runner } = makeStubRunner(0); const mode = detectAuthMode({ env: { ANTHROPIC_API_KEY: 'fake', CLAUDE_CODE_OAUTH_TOKEN: 'oat', }, runner, claudeJsonPath: MISSING_PATH, }); assert.equal(mode, 'api-key'); }); test('detectAuthMode — neither env, no claude.json → unauthenticated', () => { const { runner, calls } = makeStubRunner(0); const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: MISSING_PATH, }); assert.equal(mode, 'unauthenticated'); // Runner must NOT be invoked when claude.json is unreadable. assert.equal(calls.length, 0); }); test('detectAuthMode — claude.json onboarded + runner exit 0 → subscription-browser-only', () => { withTmp((tmp) => { const path = join(tmp, '.claude.json'); writeFileSync( path, JSON.stringify({ hasCompletedOnboarding: true, userID: 'abc' }), 'utf8' ); const { runner, calls } = makeStubRunner(0); const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path }); assert.equal(mode, 'subscription-browser-only'); assert.deepEqual(calls, [{ cmd: 'claude', args: ['auth', 'status'] }]); }); }); test('detectAuthMode — claude.json onboarded + runner exit 1 → unauthenticated', () => { withTmp((tmp) => { const path = join(tmp, '.claude.json'); writeFileSync( path, JSON.stringify({ hasCompletedOnboarding: true }), 'utf8' ); const { runner } = makeStubRunner(1); const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path }); assert.equal(mode, 'unauthenticated'); }); }); test('detectAuthMode — claude.json present but not onboarded → unauthenticated', () => { withTmp((tmp) => { const path = join(tmp, '.claude.json'); writeFileSync( path, JSON.stringify({ hasCompletedOnboarding: false }), 'utf8' ); const { runner, calls } = makeStubRunner(0); const mode = detectAuthMode({ env: {}, runner, claudeJsonPath: path }); assert.equal(mode, 'unauthenticated'); assert.equal(calls.length, 0); }); }); test('readClaudeJson — returns parsed object on valid JSON', () => { withTmp((tmp) => { const path = join(tmp, '.claude.json'); writeFileSync(path, '{"hasCompletedOnboarding": true, "x": 42}', 'utf8'); const obj = readClaudeJson(path); assert.deepEqual(obj, { hasCompletedOnboarding: true, x: 42 }); }); }); test('readClaudeJson — returns null on missing file', () => { assert.equal(readClaudeJson(MISSING_PATH), null); }); test('readClaudeJson — returns null on malformed JSON', () => { withTmp((tmp) => { const path = join(tmp, 'bad.json'); writeFileSync(path, 'not json {', 'utf8'); assert.equal(readClaudeJson(path), null); }); }); test('validateAuthForCron — api-key passes silently', () => { validateAuthForCron('api-key'); }); test('validateAuthForCron — long-oauth passes silently', () => { validateAuthForCron('long-oauth'); }); test('validateAuthForCron — subscription-browser-only throws EAUTHCRON', () => { assert.throws( () => validateAuthForCron('subscription-browser-only'), (err) => err.code === 'EAUTHCRON' && err.detectedMode === 'subscription-browser-only' && /claude setup-token/.test(err.message) && /ANTHROPIC_API_KEY/.test(err.message) ); }); test('validateAuthForCron — unauthenticated throws EAUTHCRON', () => { assert.throws( () => validateAuthForCron('unauthenticated'), (err) => err.code === 'EAUTHCRON' && err.detectedMode === 'unauthenticated' ); });