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.
867 lines
21 KiB
TypeScript
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()
|
|
})
|
|
})
|