import { afterEach, describe, expect, it } from 'vitest' import { act } from 'react' import { byId, click, setup } from './harness' import type { Harness } from './harness' import { AuthManager } from '../src/auth' let h: Harness afterEach(() => h?.cleanup()) const until = async (cond: () => boolean, ms = 1500) => { const t0 = Date.now() while (!cond()) { if (Date.now() - t0 > ms) throw new Error('timed out waiting') await act(async () => void (await new Promise((r) => setTimeout(r, 10)))) } } const status = () => h.editor.ctrl.store.get().auth.status const signIn = async () => { await until(() => status() === 'signed-out') await act(async () => void h.shadow.querySelector('[aria-label="Sign in"] .cms-btn--primary')!.click()) await until(() => status() === 'pending') h.cms.approve() await until(() => status() === 'signed-in') await h.settle() } describe('sign-in (device grant)', () => { it('shows the login panel and blocks editing while signed out', async () => { h = await setup({ auth: true }) await until(() => status() === 'signed-out') expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull() await act(async () => void click(byId('t1'))) expect(h.editor.ctrl.store.get().selected).toBeNull() const ev = new MouseEvent('click', { bubbles: true, cancelable: true }) byId('b1').querySelector('a')!.dispatchEvent(ev) expect(ev.defaultPrevented).toBe(false) // the page behaves like a normal page }) it('walks through user code -> approval -> signed in, then editing works', async () => { h = await setup({ auth: true }) await until(() => status() === 'signed-out') await act(async () => void h.shadow.querySelector('[aria-label="Sign in"] .cms-btn--primary')!.click()) await until(() => status() === 'pending') expect(h.shadow.querySelector('.cms-code')!.textContent).toBe('MOCK-1234') expect(h.shadow.querySelector('.cms-login__link')!.href).toContain('/device?user_code=MOCK-1234') h.cms.approve() await until(() => status() === 'signed-in') await h.settle() expect(h.shadow.querySelector('[aria-label="Sign in"]')).toBeNull() expect(h.editor.ctrl.store.get().toast!.text).toBe('Signed in as mock-user') await act(async () => void click(byId('t1'))) expect(h.editor.ctrl.store.get().selected).toBe('t1') }) it('refreshes transparently when the access token expires', async () => { h = await setup({ auth: true }) await signIn() const before = h.auth.headers().Authorization h.cms.expireAccess() await act(async () => void click(byId('t1'))) await h.editor.ctrl.nudge('t1', 1) await h.settle() expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1']) expect(h.auth.headers().Authorization).not.toBe(before) // a new access token was minted via the refresh token expect(status()).toBe('signed-in') }) it('a revoked session drops back to the login panel', async () => { h = await setup({ auth: true }) await signIn() await act(async () => void click(byId('t1'))) h.cms.revoke() await h.editor.ctrl.nudge('t1', 1) await h.settle() expect(status()).toBe('signed-out') expect(h.editor.ctrl.store.get().selected).toBeNull() expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull() }) it('sign out clears the session; pasted tokens work', async () => { h = await setup({ auth: true, token: 'static-token' }) await until(() => status() === 'signed-out') await h.editor.ctrl.signInWithToken('static-token') await h.settle() expect(status()).toBe('signed-in') h.editor.ctrl.signOut() expect(status()).toBe('signed-out') }) }) describe('AuthManager', () => { const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status }) const grant = { device_code: 'd', user_code: 'U', verification_uri: 'https://i/device', interval: 1, expires_in: 600 } function manager(poll: Array, extra: Partial[0]> = {}) { const sleeps: number[] = [] const calls: string[] = [] const fetch = (async (url: string) => { calls.push(String(url)) if (String(url).endsWith('/api/auth/device')) return json(200, grant) if (String(url).endsWith('/device/token')) return poll.shift()! throw new Error('unexpected ' + url) }) as unknown as typeof globalThis.fetch const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null, sleep: async (ms) => void sleeps.push(ms), ...extra }) return { auth, sleeps, calls } } it('honours slow_down by backing off 5s', async () => { const { auth, sleeps } = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'slow_down' }), json(400, { error: 'authorization_pending' }), json(200, { access_token: 'a', refresh_token: 'r', expires_in: 900 })]) expect(await auth.login()).toBe(true) expect(sleeps).toEqual([1000, 1000, 6000, 6000]) expect(auth.headers()).toEqual({ Authorization: 'Bearer a' }) }) it('runs the grant as the CMS client from /api/auth/config so the token audience matches', async () => { const bodies: string[] = [] const fetch = (async (url: string, init?: RequestInit) => { const u = String(url) if (u === '/api/auth/config') return json(200, { issuer: 'https://i', clientId: 'k6n-admin' }) bodies.push(`${u.replace('https://i', '')} ${String(init?.body)}`) if (u.endsWith('/api/auth/device')) return json(200, grant) return json(200, { access_token: 'a', expires_in: 900 }) }) as unknown as typeof globalThis.fetch const auth = new AuthManager({ fetch, persistent: null, session: null, sleep: async () => {} }) expect(await auth.login()).toBe(true) expect(bodies[0]).toContain('client_id=k6n-admin') // start expect(bodies[1]).toContain('client_id=k6n-admin') // poll: must be the same client }) it('surfaces the server reason (e.g. wrong audience) when a token is rejected', async () => { const fetch = (async () => json(401, { error: 'unauthorized: oidc: wrong audience' })) as unknown as typeof globalThis.fetch const auth = new AuthManager({ fetch, persistent: null, session: null }) auth.useToken('t') await auth.check(async () => { throw Object.assign(new Error('unauthorized: oidc: wrong audience'), { status: 401 }) }) expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: expect.stringContaining('wrong audience') }) }) it('reports denial and expiry', async () => { const denied = manager([json(400, { error: 'access_denied' })]) expect(await denied.auth.login()).toBe(false) expect(denied.auth.state.get()).toMatchObject({ status: 'signed-out', error: 'sign-in was denied' }) const expired = manager([json(400, { error: 'expired_token' })]) await expired.auth.login() expect(expired.auth.state.get().error).toMatch(/expired/) }) it('cancel stops polling quietly', async () => { let auth!: AuthManager const m = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'authorization_pending' })], { sleep: async () => auth.cancel() }) auth = m.auth expect(await auth.login()).toBe(false) expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: undefined }) }) it('production keeps the refresh token out of localStorage', async () => { const mk = () => { const m = new Map() return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v), removeItem: (k: string) => void m.delete(k), data: m } as unknown as Storage & { data: Map } } for (const persistRefresh of [false, true]) { const local = mk() const session = mk() const { auth } = manager([json(200, { access_token: 'a', refresh_token: 'secret-refresh', expires_in: 900 })], { persistent: local, session, persistRefresh }) await auth.login() expect([...local.data.values()].join('').includes('secret-refresh')).toBe(persistRefresh) expect([...session.data.values()].join('').includes('secret-refresh')).toBe(!persistRefresh) } }) it('shares one refresh between concurrent callers (refresh tokens rotate)', async () => { let refreshes = 0 const fetch = (async (url: string) => { if (String(url).endsWith('/device/refresh')) { refreshes++ return json(200, { access_token: 'new', refresh_token: 'r2', expires_in: 900 }) } return json(200, grant) }) as unknown as typeof globalThis.fetch const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null }) auth.useToken('old') ;(auth as any).tokens = { accessToken: 'old', refreshToken: 'r1', expiresAt: 0 } await Promise.all([auth.refresh(), auth.refresh(), auth.ensureFresh()]) expect(refreshes).toBe(1) expect(auth.headers()).toEqual({ Authorization: 'Bearer new' }) }) })