import { afterEach, describe, expect, it, vi } from 'vitest' import { createAction, deleteAction, deleteGroup, deleteNode, deleteUser, fetchActions, fetchGroups, fetchHealth, fetchNetwork, fetchAuthLogs, fetchNodeLogs, fetchSecurity, fetchStatus, fetchUsers, getApiBaseUrl, login, patchAction, 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('deleteNode', () => { afterEach(() => { vi.restoreAllMocks() }) it('sends DELETE by id with reauth credentials and returns nodes', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ nodes: [] }), }) await deleteNode( 'node-1', { current_password: 'correct horse battery staple extra' }, fetchMock, ) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/nodes/node-1', expect.objectContaining({ method: 'DELETE', credentials: 'include', body: JSON.stringify({ current_password: 'correct horse battery staple extra', totp_code: '', }), }), ) }) }) describe('fetchNodeLogs', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed logs payload with optional kind filter', async () => { const events = [ { id: 'e1', at: '2026-01-01T00:00:00Z', action: 'create', actor: 'Admin', node_id: 'n1', node_name: 'ct1', node_kind: 'container', }, ] const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events }), }) const payload = await fetchNodeLogs('container', fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/node-logs?kind=container', expect.objectContaining({ credentials: 'include' }), ) expect(payload.events).toEqual(events) }) }) describe('fetchAuthLogs', () => { afterEach(() => { vi.restoreAllMocks() }) it('returns parsed auth logs payload', async () => { const events = [ { id: 'a1', at: '2026-01-01T00:00:00Z', action: 'login', outcome: 'success', category: 'auth', actor: 'Admin', target: 'Admin', }, ] const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events }), }) const payload = await fetchAuthLogs(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/auth-logs', expect.objectContaining({ credentials: 'include' }), ) expect(payload.events).toEqual(events) }) it('sends idle-exempt header when requested', async () => { const { SESSION_IDLE_EXEMPT_HEADER } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [] }), }) await fetchAuthLogs(fetchMock, { idleExempt: true }) const init = fetchMock.mock.calls[0][1] as { headers: Headers } expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1') }) }) 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('sends idle-exempt header when requested', async () => { const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ nodes: [] }), }) await fetchNodes(undefined, fetchMock, { idleExempt: true }) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/nodes', expect.objectContaining({ credentials: 'include', headers: expect.any(Headers), }), ) const init = fetchMock.mock.calls[0][1] as { headers: Headers } expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1') }) it('omits idle-exempt header by default', async () => { const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ nodes: [] }), }) await fetchNodes(undefined, fetchMock) const init = fetchMock.mock.calls[0][1] as { headers: Headers } expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBeNull() }) 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', }), ) }) }) describe('actions API', () => { afterEach(() => { vi.restoreAllMocks() }) it('fetches actions', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ actions: [ { id: 'a1', name: 'Uptime', description: 'uptime', kind: 'shell', body: 'uptime', env: [], builtin: true, created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', }, ], }), }) const payload = await fetchActions(fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/actions', expect.objectContaining({ credentials: 'include' }), ) expect(payload.actions).toHaveLength(1) expect(payload.actions[0]?.name).toBe('Uptime') }) it('creates an action', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ actions: [] }), }) await createAction( { name: 'Echo', description: 'echo ip', kind: 'shell', body: 'echo {{node.ip}}', env: [{ name: 'APP_HOME', value: '/opt/app' }], }, fetchMock, ) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/actions', expect.objectContaining({ method: 'POST', credentials: 'include', body: JSON.stringify({ name: 'Echo', description: 'echo ip', kind: 'shell', body: 'echo {{node.ip}}', env: [{ name: 'APP_HOME', value: '/opt/app' }], }), }), ) }) it('patches and deletes an action', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ actions: [] }), }) await patchAction('custom-1', { name: 'Renamed', body: 'echo hi' }, fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/actions?id=custom-1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'Renamed', body: 'echo hi' }), }), ) await deleteAction('custom-1', fetchMock) expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/actions?id=custom-1', expect.objectContaining({ method: 'DELETE' }), ) }) }) describe('session idle redirect', () => { afterEach(() => { vi.restoreAllMocks() }) it('reloads to login when an authenticated call returns session_idle', async () => { const assign = vi.fn() vi.stubGlobal('location', { pathname: '/configuration/security', assign, }) const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401, clone: () => ({ json: async () => ({ error: 'session_idle' }), }), json: async () => ({ error: 'session_idle' }), }) await expect(fetchStatus(fetchMock)).rejects.toThrow() expect(assign).toHaveBeenCalledWith('/login?reason=idle') }) it('does not redirect on login 401', async () => { const assign = vi.fn() vi.stubGlobal('location', { pathname: '/login', assign, }) const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401, clone: () => ({ json: async () => ({ error: 'invalid credentials' }), }), json: async () => ({ error: 'invalid credentials' }), }) await expect(login('Admin', 'wrong', '', fetchMock)).rejects.toThrow( 'invalid credentials', ) expect(assign).not.toHaveBeenCalled() }) it('does not redirect for generic authentication required', async () => { const assign = vi.fn() vi.stubGlobal('location', { pathname: '/', assign, }) const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401, clone: () => ({ json: async () => ({ error: 'authentication required' }), }), json: async () => ({ error: 'authentication required' }), }) await expect(fetchStatus(fetchMock)).rejects.toThrow() expect(assign).not.toHaveBeenCalled() }) })