import { afterEach, describe, expect, it, vi } from 'vitest' import { deleteGroup, deleteUser, fetchGroups, fetchHealth, fetchNetwork, fetchSecurity, fetchStatus, fetchUsers, getApiBaseUrl, saveNetwork, saveSecurity, upsertGroup, } from './client' describe('getApiBaseUrl', () => { it('defaults to localhost:8080 when unset', () => { expect(getApiBaseUrl()).toBe('http://localhost:8080') }) it('keeps an empty string for same-origin requests', async () => { vi.resetModules() vi.stubEnv('VITE_API_BASE_URL', '') const clientModule = await import('./client') expect(clientModule.getApiBaseUrl()).toBe('') vi.unstubAllEnvs() vi.resetModules() }) }) describe('fetchHealth', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed health payload', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: 'ok' }), }) const health = await fetchHealth(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/health', expect.objectContaining({ credentials: 'include' }), ) expect(health).toEqual({ status: 'ok' }) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}), }) await expect(fetchHealth(fetchMock)).rejects.toThrow( 'health check failed: 503', ) }) }) describe('fetchStatus', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed status payload', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ service: 'clustercanvas', version: '0.1.0' }), }) const status = await fetchStatus(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/status', expect.objectContaining({ credentials: 'include' }), ) expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' }) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}), }) await expect(fetchStatus(fetchMock)).rejects.toThrow( 'status check failed: 503', ) }) }) describe('fetchGroups', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed groups payload', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ groups: [ { name: 'Admins', scope_kind: 'node', scope_name: 'node-1', permissions: ['nodes.read'], }, ], }), }) const payload = await fetchGroups(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/groups', expect.objectContaining({ credentials: 'include' }), ) expect(payload.groups).toEqual([ { name: 'Admins', scope_kind: 'node', scope_name: 'node-1', permissions: ['nodes.read'], }, ]) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}), }) await expect(fetchGroups(fetchMock)).rejects.toThrow( 'groups fetch failed: 503', ) }) }) describe('upsertGroup', () => { afterEach(() => { vi.restoreAllMocks() }) it('sends JSON body and returns parsed groups', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ groups: [] }), }) await upsertGroup( { name: 'Admins', scope_kind: 'node', scope_name: 'node-1', permissions: ['nodes.read'], }, fetchMock, ) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/groups', expect.objectContaining({ method: 'POST', credentials: 'include', body: JSON.stringify({ group: { name: 'Admins', scope_kind: 'node', scope_name: 'node-1', permissions: ['nodes.read'], }, }), }), ) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({}), }) await expect( upsertGroup( { name: 'Admins', scope_kind: 'node', scope_name: 'node-1', permissions: ['nodes.read'], }, fetchMock, ), ).rejects.toThrow('groups upsert failed: 400') }) }) describe('deleteGroup', () => { afterEach(() => { vi.restoreAllMocks() }) it('sends DELETE and returns parsed groups', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ groups: [] }), }) await deleteGroup('Admins', fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/groups?name=Admins', expect.objectContaining({ method: 'DELETE', credentials: 'include' }), ) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}), }) await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow( 'request failed: 404', ) }) it('surfaces API error messages', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 409, json: async () => ({ error: 'Administrators group cannot be deleted', }), }) await expect(deleteGroup('Administrators', fetchMock)).rejects.toThrow( 'Administrators group cannot be deleted', ) }) }) describe('fetchUsers', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed users payload', async () => { const users = [ { id: 'u1', username: 'Admin', enabled: true, totp_confirmed: false, group_names: ['Administrators'], created_at: '2026-01-01T00:00:00Z', password_changed_at: '2026-01-01T00:00:00Z', is_admin: true, can_delete: false, }, ] const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ users }), }) const payload = await fetchUsers(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/users', expect.objectContaining({ credentials: 'include' }), ) expect(payload.users).toEqual(users) }) }) describe('deleteUser', () => { afterEach(() => { vi.restoreAllMocks() }) it('sends DELETE by id with reauth credentials and returns users', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ users: [] }), }) await deleteUser( 'u2', { current_password: 'correct horse battery staple extra' }, fetchMock, ) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/users?id=u2', expect.objectContaining({ method: 'DELETE', credentials: 'include', body: JSON.stringify({ current_password: 'correct horse battery staple extra', totp_code: '', }), }), ) }) it('surfaces last-admin errors', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 409, json: async () => ({ error: 'cannot delete the last administrator' }), }) await expect( deleteUser('u1', { current_password: 'x' }, fetchMock), ).rejects.toThrow('cannot delete the last administrator') }) }) describe('fetchSecurity', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed security payload', async () => { const security = { idle_timeout_minutes: 30, session_lifetime_hours: 24, reauth_sensitive_actions: false, reauth_grace_minutes: 15, totp_enabled: false, } const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ security }), }) const payload = await fetchSecurity(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/security', expect.objectContaining({ credentials: 'include' }), ) expect(payload.security).toEqual(security) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}), }) await expect(fetchSecurity(fetchMock)).rejects.toThrow( 'security fetch failed: 503', ) }) }) describe('saveSecurity', () => { afterEach(() => { vi.restoreAllMocks() }) it('sends JSON body and returns parsed security', async () => { const security = { idle_timeout_minutes: 45, session_lifetime_hours: 12, reauth_sensitive_actions: true, reauth_grace_minutes: 0, totp_enabled: true, } const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ security }), }) await saveSecurity(security, fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/security', expect.objectContaining({ method: 'PUT', credentials: 'include', body: JSON.stringify({ security }), }), ) }) it('throws when the response is not ok', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({}), }) await expect( saveSecurity( { idle_timeout_minutes: 0, session_lifetime_hours: 24, reauth_sensitive_actions: false, reauth_grace_minutes: 15, totp_enabled: false, }, fetchMock, ), ).rejects.toThrow('security save failed: 400') }) }) describe('fetchNetwork', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed network payload', async () => { const network = { listen_address: '0.0.0.0', public_hostname: '', access_mode: 'open' as const, rules: [], } const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ network }), }) const payload = await fetchNetwork(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/network', expect.objectContaining({ credentials: 'include' }), ) expect(payload.network).toEqual(network) }) }) describe('saveNetwork', () => { afterEach(() => { vi.restoreAllMocks() }) it('sends JSON body and returns parsed network', async () => { const network = { listen_address: '127.0.0.1', public_hostname: 'admin.example.com', access_mode: 'whitelist' as const, rules: ['10.0.0.0/8'], } const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ network }), }) await saveNetwork(network, fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/network', expect.objectContaining({ method: 'PUT', credentials: 'include', body: JSON.stringify({ network }), }), ) }) }) describe('nodes API', () => { afterEach(() => { vi.restoreAllMocks() }) it('fetches nodes filtered by kind', async () => { const { fetchNodes } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ nodes: [] }), }) await fetchNodes('container', fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/nodes?kind=container', expect.objectContaining({ credentials: 'include' }), ) }) it('creates a node with generate options', async () => { const { createNode } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ node: { id: '11111111-2222-4333-8444-555555555555', kind: 'container', name: 'ct1', host_ip: '10.0.0.1', username: 'clustercanvas', group_name: 'Administrators', public_key: 'ssh-ed25519 AAAA', key_algo: 'ed25519', created_at: '2026-01-01T00:00:00.000Z', }, }), }) await createNode( { kind: 'container', name: 'ct1', host_ip: '10.0.0.1', username: 'clustercanvas', group_name: 'Administrators', generate: { algorithm: 'ed25519', kdf_rounds: 100 }, }, fetchMock, ) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/nodes', expect.objectContaining({ method: 'POST', credentials: 'include', }), ) }) it('posts to the SSH test endpoint', async () => { const { testNodeSSH } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: false, message: 'connection refused' }), }) const result = await testNodeSSH( '11111111-2222-4333-8444-555555555555', fetchMock, ) expect(result).toEqual({ ok: false, message: 'connection refused' }) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555/test-ssh', expect.objectContaining({ method: 'POST', credentials: 'include', }), ) }) })