Files
ClusterCanvas/webui/src/api/client.test.ts
T
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00

867 lines
21 KiB
TypeScript

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()
})
})