From 0390cc10cacabd17bdf84e555232d50c49826f22 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 12:59:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20C3.1=20=E2=80=94=20key?= =?UTF-8?q?chain=20+=20Learn=20Platform=20API-klient=20(TDD)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3 kurs-deteksjon, fase C3.1 (spec docs/c3-course-detection-plan.md §6/§7). Claude-frie, fil-frie infrastruktur-lag for kurs-detektoren (C3.4): - lib/keychain.mjs: readSecret() leser én Keychain-item via `security`-CLI, fail-soft (manglende item / ikke-macOS → null). execImpl-DI for test. - lib/learn-api.mjs: getToken (Entra client-credentials), paginate (async-gen, følger nextLink), buildApiUrl (pinner api-version), buildUpdatedAtGt (kaster på dato-only, canon Z — gotcha #1). Robusthetskontrakt §6: response.ok-sjekk (fetch kaster ikke på 4xx/5xx), AbortSignal.timeout, 429/5xx-retry som respekterer Retry-After, 4xx≠429 ikke-retry, fail-closed. fetchImpl/sleep-DI. Tester (24, alle grønne): test-learn-api (token-body, buildUpdatedAtGt, nextLink-paginering, produkt-param, alle robusthets-asserts), test-keychain, test-courses-invariant (Claude-fri + skriver-ingen-filer). Live-probe (efemer) grønn mot ekte Platform API m/ ekte creds: token + full-enum + inkrementell. Intern infrastruktur (ingen brukervendt kommando/hook/atferd endret ennå — surfacing + docs lander i C3.6). kb-update 213→237 · validate 239 · null regresjon. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/kb-update/lib/keychain.mjs | 28 +++ scripts/kb-update/lib/learn-api.mjs | 166 ++++++++++++++ .../kb-update/test-courses-invariant.test.mjs | 45 ++++ tests/kb-update/test-keychain.test.mjs | 38 ++++ tests/kb-update/test-learn-api.test.mjs | 211 ++++++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 scripts/kb-update/lib/keychain.mjs create mode 100644 scripts/kb-update/lib/learn-api.mjs create mode 100644 tests/kb-update/test-courses-invariant.test.mjs create mode 100644 tests/kb-update/test-keychain.test.mjs create mode 100644 tests/kb-update/test-learn-api.test.mjs diff --git a/scripts/kb-update/lib/keychain.mjs b/scripts/kb-update/lib/keychain.mjs new file mode 100644 index 0000000..63313b6 --- /dev/null +++ b/scripts/kb-update/lib/keychain.mjs @@ -0,0 +1,28 @@ +// keychain.mjs — Read a single secret from the macOS Keychain at runtime. +// Zero dependencies. The C3 course detector reads its Platform API credentials +// straight from the Keychain (never repo/env) — see c3-course-detection-plan.md §3. +// +// Fail-soft: a missing item (or a non-macOS host where `security` is absent) +// returns null so the caller can skip cleanly rather than crash the pipeline. + +import { execFileSync } from 'node:child_process'; + +/** + * Read a generic-password secret from the macOS Keychain. + * @param {string} service — the keychain item's service name (-s) + * @param {string} [account='ktg'] — the account name (-a) + * @param {{ execImpl?: typeof execFileSync }} [opts] — inject exec for tests + * @returns {string|null} the secret (trimmed) or null if the item is missing + */ +export function readSecret(service, account = 'ktg', { execImpl = execFileSync } = {}) { + try { + return execImpl( + 'security', + ['find-generic-password', '-s', service, '-a', account, '-w'], + { encoding: 'utf8' }, + ).trim(); + } catch { + // item missing / not macOS → caller fail-softs + return null; + } +} diff --git a/scripts/kb-update/lib/learn-api.mjs b/scripts/kb-update/lib/learn-api.mjs new file mode 100644 index 0000000..b8bc5b3 --- /dev/null +++ b/scripts/kb-update/lib/learn-api.mjs @@ -0,0 +1,166 @@ +// learn-api.mjs — Headless (no-LLM) network client for the Microsoft Learn Platform API. +// Zero dependencies (native global `fetch`, Node 18+). Writes NO files — it only +// fetches: token via Entra client-credentials, then paginates the per-type +// endpoints. See c3-course-detection-plan.md §6 + the verified contract in +// c3-course-detection-spike.md. +// +// Robustness contract (§6, decision §8 (a)) — what separates "works" from +// "professional", since raw fetch has two traps: +// 1. fetch does NOT reject on HTTP 4xx/5xx → we check response.ok explicitly. +// 2. fetch has NO default timeout → every request gets AbortSignal.timeout(). +// Retry: 429 + 5xx only, honouring Retry-After; 4xx≠429 fails immediately; a +// timeout propagates fail-closed (never retried, never swallowed). + +const API_BASE = 'https://learn.microsoft.com/api/v1'; +const API_VERSION = '2023-11-01-preview'; +const SCOPE = 'https://learn.microsoft.com/.default'; +const TOKEN_ENDPOINT = (tenantId) => + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + +const VALID_ENDPOINTS = new Set(['modules', 'learning-paths']); + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_ATTEMPTS = 3; +const BASE_BACKOFF_MS = 1_000; + +/** + * Serialize a currency cursor for the `updatedAt.gt` filter. + * Gotcha #1 (verified empirically): the API needs a FULL ISO 8601 datetime — + * a date-only value (e.g. "2026-01-01") fails closed server-side (0 hits). We + * reject date-only input outright and canonicalize to the UTC "Z" form. + * @param {string} iso — a full ISO 8601 datetime + * @returns {string} canonical full ISO datetime (…Z) + */ +export function buildUpdatedAtGt(iso) { + // Require a time component with an explicit zone (Z or ±hh:mm). Date-only is rejected. + if (typeof iso !== 'string' || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(iso)) { + throw new Error( + `updatedAt.gt needs a full ISO 8601 datetime (e.g. 2026-01-01T00:00:00Z); got: ${iso}`, + ); + } + return new Date(iso).toISOString(); +} + +/** + * Build a Platform API URL with the api-version pinned and params serialized. + * Null/empty params are omitted. Comma-separated values pass through (OR semantics). + * @param {'modules'|'learning-paths'} endpoint + * @param {Record} [params] + * @returns {string} + */ +export function buildApiUrl(endpoint, params = {}) { + if (!VALID_ENDPOINTS.has(endpoint)) { + throw new Error(`Unknown endpoint: ${endpoint} (expected 'modules' | 'learning-paths')`); + } + const url = new URL(`${API_BASE}/${endpoint}`); + url.searchParams.set('api-version', API_VERSION); + for (const [k, v] of Object.entries(params)) { + if (v != null && v !== '') url.searchParams.set(k, String(v)); + } + return url.toString(); +} + +/** + * Compute the retry delay for a retryable response. Honours Retry-After + * (delta-seconds form, the one Microsoft's rate-limiter uses); otherwise a + * linear backoff scaled by attempt number. + * @param {{ headers: { get(name: string): string|null } }} response + * @param {number} attempt — 1-based + * @returns {number} delay in ms + */ +function retryDelayMs(response, attempt) { + const retryAfter = response.headers?.get?.('retry-after'); + if (retryAfter != null && retryAfter !== '') { + const secs = Number(retryAfter); + if (Number.isFinite(secs)) return secs * 1000; + } + return BASE_BACKOFF_MS * attempt; +} + +/** + * Perform a request with the robustness contract and return parsed JSON. + * @param {string} url + * @param {RequestInit} [requestOptions] + * @param {{ fetchImpl?, sleep?, timeoutMs?, maxAttempts? }} [opts] + * @returns {Promise} + */ +async function requestJson(url, requestOptions = {}, opts = {}) { + const { + fetchImpl = globalThis.fetch, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + timeoutMs = DEFAULT_TIMEOUT_MS, + maxAttempts = DEFAULT_MAX_ATTEMPTS, + } = opts; + + let attempt = 0; + while (true) { + attempt++; + // Trap #2: no default timeout — abort each attempt after timeoutMs. A timeout + // (or any network error) rejects here and propagates fail-closed; it is NOT + // retried (only HTTP 429/5xx are). + const response = await fetchImpl(url, { + ...requestOptions, + signal: AbortSignal.timeout(timeoutMs), + }); + + // Trap #1: fetch does not reject on 4xx/5xx — check explicitly. + if (response.ok) return response.json(); + + const status = response.status; + const retryable = status === 429 || (status >= 500 && status < 600); + if (retryable && attempt < maxAttempts) { + await sleep(retryDelayMs(response, attempt)); + continue; + } + throw new Error(`Learn Platform API HTTP ${status} for ${url}`); + } +} + +/** + * Acquire a bearer token via Entra client-credentials. + * @param {{ tenantId: string, clientId: string, clientSecret: string }} creds + * @param {object} [opts] — passed through to requestJson (fetchImpl/timeoutMs/…) + * @returns {Promise} the access token + */ +export async function getToken({ tenantId, clientId, clientSecret }, opts = {}) { + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + scope: SCOPE, + }); + const json = await requestJson( + TOKEN_ENDPOINT(tenantId), + { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }, + opts, + ); + if (!json.access_token) throw new Error('Token response had no access_token'); + return json.access_token; +} + +/** + * Paginate a Platform API endpoint, yielding each item across all pages. + * The token is acquired once by the caller and reused on every request. + * @param {'modules'|'learning-paths'} endpoint + * @param {Record} params — products / updatedAt.gt / maxpagesize / … + * @param {string} token — bearer token + * @param {object} [opts] — passed through to requestJson + * @yields {object} an item from `value` + */ +export async function* paginate(endpoint, params, token, opts = {}) { + let url = buildApiUrl(endpoint, params); + while (url) { + const json = await requestJson( + url, + { headers: { authorization: `Bearer ${token}` } }, + opts, + ); + for (const item of json.value ?? []) yield item; + url = json.nextLink || null; + } +} diff --git a/tests/kb-update/test-courses-invariant.test.mjs b/tests/kb-update/test-courses-invariant.test.mjs new file mode 100644 index 0000000..a16fbd6 --- /dev/null +++ b/tests/kb-update/test-courses-invariant.test.mjs @@ -0,0 +1,45 @@ +// tests/kb-update/test-courses-invariant.test.mjs +// Architecture-invariant guard for the C3 course-detection libs (spec §2, §6). +// The network/secret layers are Claude-free and write NO files: +// - learn-api.mjs is a pure network client (writes nothing, no KB/ledger). +// - keychain.mjs reads secrets via the `security` CLI only. +// As with test-discover-invariant, the checks are import/source-specific: a +// comment that merely *describes* the invariant must not be able to satisfy it. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LIB = join(__dirname, '..', '..', 'scripts', 'kb-update', 'lib'); +const learnApiSrc = readFileSync(join(LIB, 'learn-api.mjs'), 'utf8'); +const keychainSrc = readFileSync(join(LIB, 'keychain.mjs'), 'utf8'); + +const importLines = (src) => src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n'); + +test('learn-api is Claude-free (no claude/anthropic references)', () => { + assert.doesNotMatch(learnApiSrc, /claude|anthropic/i); +}); + +test('keychain is Claude-free (no claude/anthropic references)', () => { + assert.doesNotMatch(keychainSrc, /claude|anthropic/i); +}); + +test('learn-api writes NO files (no fs / atomic-write / save* imports)', () => { + const imports = importLines(learnApiSrc); + assert.doesNotMatch(imports, /node:fs/); + assert.doesNotMatch(imports, /atomic-write/); + assert.doesNotMatch(imports, /\bsaveRegistry\b|\bsaveDecisions\b|\bsaveReport\b|\bsaveTaxonomy\b/); +}); + +test('learn-api also does not call fs write functions inline', () => { + assert.doesNotMatch(learnApiSrc, /\bwriteFileSync\b|\brenameSync\b|\bmkdirSync\b/); +}); + +test('keychain imports only child_process (reads via the security CLI)', () => { + const imports = importLines(keychainSrc); + assert.match(imports, /node:child_process/); + assert.doesNotMatch(imports, /node:fs|atomic-write|decisions-io|registry-io/); +}); diff --git a/tests/kb-update/test-keychain.test.mjs b/tests/kb-update/test-keychain.test.mjs new file mode 100644 index 0000000..9d71de9 --- /dev/null +++ b/tests/kb-update/test-keychain.test.mjs @@ -0,0 +1,38 @@ +// tests/kb-update/test-keychain.test.mjs +// Unit tests for scripts/kb-update/lib/keychain.mjs (C3.1). +// readSecret reads a single Keychain item via the `security` CLI. Tests inject a +// stub exec impl so no real Keychain / no real secret ever touches the suite +// (spec §3: "Tester injiserer falske creds via en stub"). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readSecret } from '../../scripts/kb-update/lib/keychain.mjs'; + +test('readSecret — returns the trimmed secret via injected exec', () => { + const calls = []; + const execImpl = (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return 'secret-value\n'; + }; + const v = readSecret('learn-platform-tenant-id', 'ktg', { execImpl }); + assert.equal(v, 'secret-value'); + assert.equal(calls.length, 1); + assert.equal(calls[0].cmd, 'security'); + assert.deepEqual(calls[0].args, [ + 'find-generic-password', '-s', 'learn-platform-tenant-id', '-a', 'ktg', '-w', + ]); + assert.equal(calls[0].opts.encoding, 'utf8'); +}); + +test('readSecret — returns null when the item is missing (exec throws)', () => { + const execImpl = () => { throw new Error('SecKeychain item not found'); }; + assert.equal(readSecret('does-not-exist', 'ktg', { execImpl }), null); +}); + +test('readSecret — defaults account to ktg', () => { + let captured; + const execImpl = (_cmd, args) => { captured = args; return 'x'; }; + readSecret('svc', undefined, { execImpl }); + assert.ok(captured.includes('-a')); + assert.equal(captured[captured.indexOf('-a') + 1], 'ktg'); +}); diff --git a/tests/kb-update/test-learn-api.test.mjs b/tests/kb-update/test-learn-api.test.mjs new file mode 100644 index 0000000..83dfdf8 --- /dev/null +++ b/tests/kb-update/test-learn-api.test.mjs @@ -0,0 +1,211 @@ +// tests/kb-update/test-learn-api.test.mjs +// Unit tests for scripts/kb-update/lib/learn-api.mjs (C3.1) — the Claude-free +// network client for the Microsoft Learn Platform API. Covers the gate criteria +// in c3-course-detection-plan.md §7 (C3.1): +// - token-body building (client-credentials) +// - buildUpdatedAtGt: throws on date-only, emits full ISO (gotcha #1) +// - nextLink pagination + product param +// - robustness contract (§6, decision §8 (a)): non-ok HTTP throws (fetch does +// not), 429 retries respecting Retry-After, 4xx≠429 not retried, timeout +// (AbortSignal) propagates fail-closed. +// All network is stubbed via injected fetchImpl/sleep — no live calls in CI. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildUpdatedAtGt, + buildApiUrl, + getToken, + paginate, +} from '../../scripts/kb-update/lib/learn-api.mjs'; + +// --- test helpers --------------------------------------------------------- + +// A minimal Response-shaped object: the wrapper relies on .ok, .status, +// .headers.get(), and .json() — nothing else. +function res(body, { status = 200, headers = {} } = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + json: async () => body, + }; +} + +// fetch stub: a queue of responses (or (url,options)=>response fns). Records calls. +function stubFetch(queue) { + const calls = []; + const impl = async (url, options) => { + calls.push({ url, options }); + const next = queue.shift(); + if (next === undefined) throw new Error('stubFetch: no more queued responses'); + return typeof next === 'function' ? next(url, options) : next; + }; + impl.calls = calls; + return impl; +} + +// sleep spy: records requested delays, resolves immediately (no real waiting). +function spySleep() { + const delays = []; + const fn = async (ms) => { delays.push(ms); }; + fn.delays = delays; + return fn; +} + +async function drain(asyncIter) { + const out = []; + for await (const x of asyncIter) out.push(x); + return out; +} + +// --- buildUpdatedAtGt (gotcha #1: full ISO required) ---------------------- + +test('buildUpdatedAtGt — throws on date-only input (fail-closed on server)', () => { + assert.throws(() => buildUpdatedAtGt('2026-01-01'), /full ISO|datetime/i); +}); + +test('buildUpdatedAtGt — accepts full ISO and emits canonical Z form', () => { + assert.equal(buildUpdatedAtGt('2026-01-01T00:00:00Z'), '2026-01-01T00:00:00.000Z'); + // an explicit offset is normalized to UTC Z + assert.equal(buildUpdatedAtGt('2026-01-01T02:00:00+02:00'), '2026-01-01T00:00:00.000Z'); +}); + +// --- buildApiUrl (api-version pin + product param) ------------------------ + +test('buildApiUrl — pins api-version and serializes the product param', () => { + const u = new URL(buildApiUrl('modules', { products: 'azure-openai' })); + assert.equal(u.pathname, '/api/v1/modules'); + assert.equal(u.searchParams.get('api-version'), '2023-11-01-preview'); + assert.equal(u.searchParams.get('products'), 'azure-openai'); +}); + +test('buildApiUrl — comma-separated products survive (OR semantics)', () => { + const u = new URL(buildApiUrl('modules', { products: 'azure-openai,power-automate' })); + assert.equal(u.searchParams.get('products'), 'azure-openai,power-automate'); +}); + +test('buildApiUrl — learning-paths is the kebab-case endpoint', () => { + const u = new URL(buildApiUrl('learning-paths', {})); + assert.equal(u.pathname, '/api/v1/learning-paths'); +}); + +test('buildApiUrl — rejects an unknown endpoint', () => { + assert.throws(() => buildApiUrl('catalog', {}), /endpoint/i); +}); + +test('buildApiUrl — omits null/empty params', () => { + const u = new URL(buildApiUrl('modules', { products: '', 'updatedAt.gt': null })); + assert.equal(u.searchParams.has('products'), false); + assert.equal(u.searchParams.has('updatedAt.gt'), false); +}); + +// --- getToken (client-credentials body) ----------------------------------- + +test('getToken — builds the client-credentials body and returns access_token', async () => { + const fetchImpl = stubFetch([res({ access_token: 'tok-123', expires_in: 3599 })]); + const token = await getToken( + { tenantId: 'TENANT', clientId: 'CLIENT', clientSecret: 'SECRET' }, + { fetchImpl }, + ); + assert.equal(token, 'tok-123'); + + const { url, options } = fetchImpl.calls[0]; + assert.equal(url, 'https://login.microsoftonline.com/TENANT/oauth2/v2.0/token'); + assert.equal(options.method, 'POST'); + const body = options.body; // URLSearchParams + assert.equal(body.get('grant_type'), 'client_credentials'); + assert.equal(body.get('client_id'), 'CLIENT'); + assert.equal(body.get('client_secret'), 'SECRET'); + assert.equal(body.get('scope'), 'https://learn.microsoft.com/.default'); +}); + +test('getToken — throws if the response lacks an access_token', async () => { + const fetchImpl = stubFetch([res({ error: 'invalid_client' })]); + await assert.rejects( + getToken({ tenantId: 't', clientId: 'c', clientSecret: 's' }, { fetchImpl }), + /access_token/i, + ); +}); + +// --- paginate (nextLink cursor) ------------------------------------------- + +test('paginate — follows nextLink across pages and yields all items', async () => { + const fetchImpl = stubFetch([ + res({ value: [{ id: 'a' }, { id: 'b' }], nextLink: 'https://learn.microsoft.com/api/v1/modules?page=2' }), + res({ value: [{ id: 'c' }], nextLink: null }), + ]); + const items = []; + for await (const it of paginate('modules', { products: 'azure-openai' }, 'tok', { fetchImpl })) { + items.push(it.id); + } + assert.deepEqual(items, ['a', 'b', 'c']); + // the second request uses the nextLink verbatim + assert.equal(fetchImpl.calls[1].url, 'https://learn.microsoft.com/api/v1/modules?page=2'); + // bearer token on every request + assert.equal(fetchImpl.calls[0].options.headers.authorization, 'Bearer tok'); + assert.equal(fetchImpl.calls[1].options.headers.authorization, 'Bearer tok'); +}); + +test('paginate — empty value array yields nothing', async () => { + const fetchImpl = stubFetch([res({ value: [], nextLink: null })]); + assert.equal((await drain(paginate('modules', {}, 'tok', { fetchImpl }))).length, 0); +}); + +// --- robustness contract (§6 / decision §8 (a)) --------------------------- + +test('robustness — non-ok status throws (fetch does not; the wrapper must)', async () => { + const fetchImpl = stubFetch([res({}, { status: 404 })]); + const sleep = spySleep(); + await assert.rejects(drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })), /404/); + assert.equal(sleep.delays.length, 0, '404 is not retried'); +}); + +test('robustness — 429 retries and respects Retry-After', async () => { + const fetchImpl = stubFetch([ + res({}, { status: 429, headers: { 'retry-after': '2' } }), + res({ value: [{ id: 'a' }], nextLink: null }), + ]); + const sleep = spySleep(); + const items = await drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })); + assert.deepEqual(items.map((i) => i.id), ['a']); + assert.deepEqual(sleep.delays, [2000], 'waited Retry-After seconds, in ms'); +}); + +test('robustness — 4xx other than 429 is not retried (immediate throw)', async () => { + const fetchImpl = stubFetch([res({}, { status: 401 })]); + const sleep = spySleep(); + await assert.rejects(drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })), /401/); + assert.equal(fetchImpl.calls.length, 1, '401 is not retried'); + assert.equal(sleep.delays.length, 0); +}); + +test('robustness — 5xx retries up to maxAttempts then fails closed', async () => { + const fetchImpl = stubFetch([ + res({}, { status: 503 }), + res({}, { status: 503 }), + res({}, { status: 503 }), + ]); + const sleep = spySleep(); + await assert.rejects( + drain(paginate('modules', {}, 'tok', { fetchImpl, sleep, maxAttempts: 3 })), + /503/, + ); + assert.equal(fetchImpl.calls.length, 3, 'three attempts'); + assert.equal(sleep.delays.length, 2, 'slept between the three attempts'); +}); + +test('robustness — timeout (AbortSignal) propagates fail-closed, not retried', async () => { + let signalSeen = null; + const fetchImpl = async (_url, options) => { + signalSeen = options.signal; + throw new DOMException('The operation timed out.', 'TimeoutError'); + }; + const sleep = spySleep(); + await assert.rejects( + drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })), + (e) => e.name === 'TimeoutError', + ); + assert.ok(signalSeen instanceof AbortSignal, 'the request wired an AbortSignal.timeout'); + assert.equal(sleep.delays.length, 0, 'a timeout is not retried'); +});