/** * auth-client.test — the SmartStack API client against the VERIFIED backend * contracts: login response parse, tolerant roles-list shapes, 201/409 * create classifications, tenant-conflict handling, header assembly. * * Every call asserts the URL it hits: the §47 regression (a route that never * existed, /api/administration/roles) survived precisely because no test * pinned the path. */ import { describe, it, expect } from 'vitest'; import { authHeaders, bulkAssignApps, changePassword, createTenantB2C, createUser, findTenantBySlug, listApplications, listRoles, login, probeApi, } from '../auth-client.js'; import type { FetchLike } from '../http.js'; const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); const fetchReturning = (body: unknown, status = 200): FetchLike => async () => json(body, status); /** Fetch stub that records every requested URL (and init) for path assertions. */ const capturing = (body: unknown, status = 200): { fetchImpl: FetchLike; urls: string[] } => { const urls: string[] = []; return { urls, fetchImpl: async (url) => { urls.push(url); return json(body, status); }, }; }; const LOGIN_OK = { token: 'jwt-token', refreshToken: 'r', user: { id: 'u1', email: 'a@b.c', roles: ['admin'], permissions: ['administration.users.read'], tenants: [{ id: 't1', slug: 'uat' }], }, mustChangePassword: false, }; describe('login', () => { it('POSTs /api/auth/login and parses token, roles, permissions and tenant slugs on 200', async () => { const { fetchImpl, urls } = capturing(LOGIN_OK); const res = await login({ apiUrl: 'http://x', fetchImpl }, 'a@b.c', 'pw'); expect(urls).toEqual(['http://x/api/auth/login']); expect(res.ok).toBe(true); expect(res.token).toBe('jwt-token'); expect(res.user?.roles).toEqual(['admin']); expect(res.user?.tenantSlugs).toEqual(['uat']); expect(res.mustChangePassword).toBe(false); }); it('surfaces the backend message on 401', async () => { const res = await login( { apiUrl: 'http://x', fetchImpl: fetchReturning({ message: 'Identifiants invalides', code: 'INVALID_CREDENTIALS' }, 401) }, 'a@b.c', 'bad', ); expect(res.ok).toBe(false); expect(res.status).toBe(401); expect(res.error).toBe('Identifiants invalides'); }); it('flags mustChangePassword from either the envelope or the user', async () => { const res = await login( { apiUrl: 'http://x', fetchImpl: fetchReturning({ ...LOGIN_OK, mustChangePassword: true }) }, 'a@b.c', 'pw', ); expect(res.ok).toBe(true); expect(res.mustChangePassword).toBe(true); }); }); describe('listRoles', () => { const ROLES = [ { id: 'r1', name: 'admin' }, { id: 'r2', name: 'viewer' }, ]; it('GETs /api/administration/permissions/roles — the route that actually exists (§47)', async () => { const { fetchImpl, urls } = capturing(ROLES); const res = await listRoles({ apiUrl: 'http://x', fetchImpl }, 't'); expect(urls).toEqual(['http://x/api/administration/permissions/roles']); expect(res.ok).toBe(true); }); it.each([ ['bare array', ROLES], ['items envelope', { items: ROLES }], ['data envelope', { data: ROLES }], ['roles envelope', { roles: ROLES }], ])('tolerates the %s shape', async (_label, payload) => { const res = await listRoles({ apiUrl: 'http://x', fetchImpl: fetchReturning(payload) }, 't'); expect(res.ok).toBe(true); expect(res.roles).toEqual(ROLES); }); it('carries code as its OWN facet — never flattened into name (§51)', async () => { const res = await listRoles( { apiUrl: 'http://x', fetchImpl: fetchReturning([ { id: 'r1', name: 'Gestionnaire de flotte', code: 'fleet-manager' }, { id: 'r2', name: 'Administrateur de plateforme', code: null }, { id: 'r3', label: 'Manager' }, ]), }, 't', ); expect(res.roles).toEqual([ { id: 'r1', name: 'Gestionnaire de flotte', code: 'fleet-manager' }, { id: 'r2', name: 'Administrateur de plateforme' }, { id: 'r3', name: 'Manager' }, ]); }); it('drops an entry with a code but no display name (code is a key, not a label)', async () => { const res = await listRoles( { apiUrl: 'http://x', fetchImpl: fetchReturning([{ id: 'r2', code: 'viewer' }]) }, 't', ); expect(res.roles).toEqual([]); }); it('rejects an unrecognized payload shape', async () => { const res = await listRoles({ apiUrl: 'http://x', fetchImpl: fetchReturning({ weird: true }) }, 't'); expect(res.ok).toBe(false); expect(res.error).toContain('shape'); }); }); describe('createUser / createTenantB2C', () => { const user = { email: 'u@x', password: 'p', firstName: 'U', lastName: 'X', roleIds: ['r1'] }; it('POSTs the expected admin routes', async () => { const users = capturing({ id: 'new-id' }, 201); await createUser({ apiUrl: 'http://x', fetchImpl: users.fetchImpl }, 't', user); expect(users.urls).toEqual(['http://x/api/administration/users']); const tenants = capturing({ id: 't-1' }, 201); await createTenantB2C({ apiUrl: 'http://x', fetchImpl: tenants.fetchImpl }, 't', { name: 'UAT', slug: 'uat' }); expect(tenants.urls).toEqual(['http://x/api/administration/tenants/b2c']); }); it('classifies 201 created (with id) and 409 exists', async () => { const created = await createUser({ apiUrl: 'http://x', fetchImpl: fetchReturning({ id: 'new-id' }, 201) }, 't', user); expect(created).toMatchObject({ outcome: 'created', userId: 'new-id' }); const exists = await createUser({ apiUrl: 'http://x', fetchImpl: fetchReturning({}, 409) }, 't', user); expect(exists.outcome).toBe('exists'); }); it('treats a duplicate-slug 400 as tenant exists', async () => { const res = await createTenantB2C( { apiUrl: 'http://x', fetchImpl: fetchReturning({ message: 'slug already exists' }, 400) }, 't', { name: 'UAT', slug: 'uat' }, ); expect(res.outcome).toBe('exists'); }); it('keeps other 400s as failures with the message', async () => { const res = await createUser( { apiUrl: 'http://x', fetchImpl: fetchReturning({ message: 'password too weak' }, 400) }, 't', user, ); expect(res.outcome).toBe('failed'); expect(res.error).toBe('password too weak'); }); }); describe('listApplications', () => { const APPS = [ { id: 'a1', code: 'administration', name: 'Administration' }, { id: 'a2', code: 'flotte' }, ]; it('GETs /api/administration/applications and reads id/code/name', async () => { const { fetchImpl, urls } = capturing(APPS); const res = await listApplications({ apiUrl: 'http://x', fetchImpl }, 't'); expect(urls).toEqual(['http://x/api/administration/applications']); expect(res.ok).toBe(true); expect(res.applications).toEqual(APPS); }); it.each([ ['items envelope', { items: APPS }], ['data envelope', { data: APPS }], ['applications envelope', { applications: APPS }], ])('tolerates the %s shape', async (_label, payload) => { const res = await listApplications({ apiUrl: 'http://x', fetchImpl: fetchReturning(payload) }, 't'); expect(res.ok).toBe(true); expect(res.applications).toHaveLength(2); }); it('fails on non-200 with the status in the error', async () => { const res = await listApplications({ apiUrl: 'http://x', fetchImpl: fetchReturning({}, 403) }, 't'); expect(res.ok).toBe(false); expect(res.error).toContain('403'); }); }); describe('bulkAssignApps', () => { it('POSTs /api/administration/tenants/{id}/applications/bulk with ONLY the provided fields', async () => { let sentBody: unknown; const urls: string[] = []; const fetchImpl: FetchLike = async (url, init) => { urls.push(url); sentBody = JSON.parse(String(init?.body)); return json({}, 200); }; const res = await bulkAssignApps({ apiUrl: 'http://x', fetchImpl }, 't', 't-1', { applicationIds: ['a1', 'a2'] }); expect(urls).toEqual(['http://x/api/administration/tenants/t-1/applications/bulk']); expect(sentBody).toEqual({ applicationIds: ['a1', 'a2'] }); expect(res.ok).toBe(true); }); it('treats 409 as already assigned (idempotent re-run) and other errors as failures', async () => { const dup = await bulkAssignApps({ apiUrl: 'http://x', fetchImpl: fetchReturning({}, 409) }, 't', 't-1', { applicationIds: ['a1'], }); expect(dup).toMatchObject({ ok: true, alreadyAssigned: true }); const bad = await bulkAssignApps( { apiUrl: 'http://x', fetchImpl: fetchReturning({ message: 'no such tenant' }, 400) }, 't', 't-1', { applicationIds: ['a1'] }, ); expect(bad.ok).toBe(false); expect(bad.error).toBe('no such tenant'); }); }); describe('findTenantBySlug', () => { const TENANTS = [ { id: 't-sys', slug: 'system', name: 'System' }, { id: 't-uat', slug: 'UAT', name: 'UAT Tenant' }, ]; it('GETs /api/administration/tenants WITH the server-side search filter (paginated endpoint) and matches the slug case-insensitively', async () => { const { fetchImpl, urls } = capturing(TENANTS); const res = await findTenantBySlug({ apiUrl: 'http://x', fetchImpl }, 't', 'uat'); // The endpoint pages at 20 — without ?search= the tenant can sit past page 1. expect(urls).toEqual(['http://x/api/administration/tenants?search=uat']); expect(res).toMatchObject({ ok: true, tenantId: 't-uat' }); }); it('URL-encodes the slug in the search filter', async () => { const { fetchImpl, urls } = capturing([]); await findTenantBySlug({ apiUrl: 'http://x', fetchImpl }, 't', 'uat & co'); expect(urls).toEqual(['http://x/api/administration/tenants?search=uat%20%26%20co']); }); it('returns ok WITHOUT a tenantId when the slug is unknown', async () => { const res = await findTenantBySlug({ apiUrl: 'http://x', fetchImpl: fetchReturning(TENANTS) }, 't', 'nope'); expect(res.ok).toBe(true); expect(res.tenantId).toBeUndefined(); }); }); describe('changePassword / probeApi / authHeaders', () => { it('changePassword ok on 200, error message otherwise', async () => { expect((await changePassword({ apiUrl: 'http://x', fetchImpl: fetchReturning({ message: 'ok' }) }, 't', 'a', 'b')).ok).toBe(true); const bad = await changePassword( { apiUrl: 'http://x', fetchImpl: fetchReturning({ message: 'policy' }, 400) }, 't', 'a', 'b', ); expect(bad.ok).toBe(false); expect(bad.error).toBe('policy'); }); it('probeApi is an IDENTITY probe on /api/config/features: 200 → up (§52)', async () => { const { fetchImpl, urls } = capturing({ multiTenantEnabled: true }); const res = await probeApi({ apiUrl: 'http://x', fetchImpl }); expect(urls).toEqual(['http://x/api/config/features']); expect(res).toEqual({ up: true, status: 200 }); }); it('probeApi reports DOWN on 404 (port squatter) and on network failure', async () => { const squatted = await probeApi({ apiUrl: 'http://x', fetchImpl: fetchReturning({}, 404) }); expect(squatted.up).toBe(false); expect(squatted.status).toBe(404); expect(squatted.error).toContain('not a reachable SmartStack app'); const down = await probeApi({ apiUrl: 'http://x', fetchImpl: async () => { throw new Error('refused'); }, }); expect(down.up).toBe(false); }); it('authHeaders assembles Bearer + tenant header only when present', () => { expect(authHeaders('tok', 'uat')).toEqual({ Authorization: 'Bearer tok', 'X-Tenant-Slug': 'uat' }); expect(authHeaders(null)).toEqual({}); }); });