Files
ClusterCanvas/webui/src/api/client.test.ts
T
Squid f4dc8f63d7 Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
2026-07-18 16:39:10 +02:00

560 lines
13 KiB
TypeScript

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',
}),
)
})
})