ktg-plugin-marketplace/plugins/ms-ai-architect/tests/kb-update/test-auth-mode.test.mjs
Kjell Tore Guttormsen 2b3f544f86 feat(ms-ai-architect): add lib/auth-mode for cron-safe auth detection [skip-docs]
Foundation lib for v1.12.0 cron rewrite. Detects which Claude auth mode is
in scope and rejects modes that are architecturally incompatible with cron.

Resolution order:
- ANTHROPIC_API_KEY env-var → 'api-key'
- CLAUDE_CODE_OAUTH_TOKEN env-var → 'long-oauth'
- ~/.claude.json onboarded + runner exit 0 → 'subscription-browser-only'
- otherwise → 'unauthenticated'

Subscription browser-OAuth tokens expire ~15h and cannot survive cron — the
detector flags them explicitly so validateAuthForCron throws EAUTHCRON with
a remediation message pointing to `claude setup-token` or ANTHROPIC_API_KEY.

Both runner (subprocess invoker) and claudeJsonPath (~/.claude.json) are
dependency-injected. Tests stub them — no real subprocess spawn, no home-
directory reads.

15/15 tests pass: precedence, env-var detection, onboarded subscription,
non-onboarded fallback, validateAuthForCron throw paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 10:52:53 +02:00

181 lines
5.4 KiB
JavaScript

// 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'
);
});