import { describe, expect, it, vi } from 'vitest' import { encodeBase64UrlBytes } from 'relay-contracts' import { readConfig } from '../src/config' import { createApiClient, type ApiClient, type ApiClientV08 } from '../src/api-client' import { ApiError } from '../src/errors' const cfg = readConfig({ protocol: 'https:', host: 'alice.term.example.com', hostname: 'alice.term.example.com', }) const validHost = { hostId: '11111111-1111-4111-8111-111111111111', accountId: '22222222-2222-4222-8222-222222222222', subdomain: 'alice', agentPubkey: encodeBase64UrlBytes(new Uint8Array([1, 2, 3, 4])), enrollFpr: 'fpr-abc', status: 'online', lastSeen: '2026-01-01T00:00:00Z', createdAt: '2026-01-01T00:00:00Z', revokedAt: null, } /** Build a mock fetch returning `body` as JSON with the given status. Records every call. */ function mockFetch(body: unknown, status = 200): { fetch: typeof fetch; calls: Request[] } { const calls: Request[] = [] const fn = vi.fn(async (url: string, init?: RequestInit) => { calls.push(new Request(`https://alice.term.example.com${url}`, init)) return { ok: status >= 200 && status < 300, status, json: async () => body, } as Response }) return { fetch: fn as unknown as typeof fetch, calls } } describe('createApiClient — Zod validation + INV3 (T2)', () => { it('listHosts sends NO account_id/tenant_id in url, query, or body (INV3)', async () => { const { fetch, calls } = mockFetch([validHost]) const api = createApiClient(cfg, fetch) const hosts = await api.listHosts() expect(hosts).toHaveLength(1) expect(hosts[0]?.enrollFpr).toBe('fpr-abc') expect(hosts[0]?.agentPubkey).toBeInstanceOf(Uint8Array) const req = calls[0]! expect(req.url).not.toMatch(/account_id|tenant_id/i) const body = await req.text() expect(body).not.toMatch(/account_id|tenant_id/i) }) it('requestPairingCode posts an empty body — account derived server-side (INV3)', async () => { const { fetch, calls } = mockFetch({ code: 'ABCD-1234', expiresAt: '2026-01-01T00:02:00Z' }) const api = createApiClient(cfg, fetch) const out = await api.requestPairingCode() expect(out.code).toBe('ABCD-1234') const body = await calls[0]!.text() expect(body).not.toMatch(/account_id|tenant_id/i) expect(JSON.parse(body)).toEqual({}) }) it('getHost returns a Zod-validated HostRecord with a non-empty enrollFpr (T8 trust root)', async () => { const { fetch } = mockFetch(validHost) const api = createApiClient(cfg, fetch) const host = await api.getHost('11111111-1111-4111-8111-111111111111') expect(host.enrollFpr).toBe('fpr-abc') }) it('throws ApiError(parse) on a malformed host (missing subdomain) — never a torn object', async () => { const bad = { ...validHost, subdomain: undefined } const { fetch } = mockFetch(bad) const api = createApiClient(cfg, fetch) await expect(api.getHost('h1')).rejects.toBeInstanceOf(ApiError) await expect(api.getHost('h1')).rejects.toMatchObject({ kind: 'parse' }) }) it('throws ApiError(parse) when enrollFpr is empty (no relay-forwarded fallback for T8)', async () => { const { fetch } = mockFetch({ ...validHost, enrollFpr: '' }) const api = createApiClient(cfg, fetch) await expect(api.getHost('h1')).rejects.toMatchObject({ kind: 'parse' }) }) it('maps 401 → ApiError(unauthenticated) (surfaces re-login, no silent swallow)', async () => { const { fetch } = mockFetch({}, 401) const api = createApiClient(cfg, fetch) await expect(api.listHosts()).rejects.toMatchObject({ kind: 'unauthenticated' }) }) it('maps 403 → ApiError(forbidden) (INV1 enforced upstream, respected here)', async () => { const { fetch } = mockFetch({}, 403) const api = createApiClient(cfg, fetch) await expect(api.getHost('foreign')).rejects.toMatchObject({ kind: 'forbidden' }) }) it('v0.9+ issueCapabilityToken returns the opaque raw token', async () => { const { fetch } = mockFetch({ token: 'v2.public.rawtoken' }) const api = createApiClient(cfg, fetch) const token = await api.issueCapabilityToken('h1', ['attach']) expect(token).toBe('v2.public.rawtoken') }) it('v0.8 phasing: issueCapabilityToken is ABSENT from ApiClientV08 (structural guard)', () => { const { fetch } = mockFetch([validHost]) const client: ApiClient = createApiClient(cfg, fetch) const v08: ApiClientV08 = client // @ts-expect-error — issueCapabilityToken is v0.9+, not on the v0.8 surface a v0.8 build uses. void v08.issueCapabilityToken }) })