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.
This commit is contained in:
2026-07-18 16:39:10 +02:00
parent b93b7519ec
commit f4dc8f63d7
31 changed files with 4801 additions and 223 deletions
+100 -6
View File
@@ -288,17 +288,28 @@ describe('deleteUser', () => {
vi.restoreAllMocks()
})
it('sends DELETE by id and returns users', async () => {
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', fetchMock)
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' }),
expect.objectContaining({
method: 'DELETE',
credentials: 'include',
body: JSON.stringify({
current_password: 'correct horse battery staple extra',
totp_code: '',
}),
}),
)
})
@@ -309,9 +320,9 @@ describe('deleteUser', () => {
json: async () => ({ error: 'cannot delete the last administrator' }),
})
await expect(deleteUser('u1', fetchMock)).rejects.toThrow(
'cannot delete the last administrator',
)
await expect(
deleteUser('u1', { current_password: 'x' }, fetchMock),
).rejects.toThrow('cannot delete the last administrator')
})
})
@@ -463,3 +474,86 @@ describe('saveNetwork', () => {
)
})
})
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',
}),
)
})
})