Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+179 -18
View File
@@ -1,11 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
deleteGroup,
deleteUser,
fetchGroups,
fetchHealth,
fetchNetwork,
fetchSecurity,
fetchStatus,
fetchUsers,
getApiBaseUrl,
saveNetwork,
saveSecurity,
upsertGroup,
} from './client'
@@ -14,6 +18,15 @@ 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', () => {
@@ -29,7 +42,10 @@ describe('fetchHealth', () => {
const health = await fetchHealth(fetchMock)
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/health')
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/health',
expect.objectContaining({ credentials: 'include' }),
)
expect(health).toEqual({ status: 'ok' })
})
@@ -61,6 +77,7 @@ describe('fetchStatus', () => {
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/status',
expect.objectContaining({ credentials: 'include' }),
)
expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' })
})
@@ -100,7 +117,10 @@ describe('fetchGroups', () => {
const payload = await fetchGroups(fetchMock)
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups')
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/groups',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.groups).toEqual([
{
name: 'Admins',
@@ -145,18 +165,21 @@ describe('upsertGroup', () => {
fetchMock,
)
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
group: {
name: 'Admins',
scope_kind: 'node',
scope_name: 'node-1',
permissions: ['nodes.read'],
},
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 () => {
@@ -195,7 +218,7 @@ describe('deleteGroup', () => {
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/groups?name=Admins',
{ method: 'DELETE' },
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
)
})
@@ -207,7 +230,87 @@ describe('deleteGroup', () => {
})
await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow(
'groups delete failed: 404',
'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 and returns users', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ users: [] }),
})
await deleteUser('u2', fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/users?id=u2',
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
)
})
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', fetchMock)).rejects.toThrow(
'cannot delete the last administrator',
)
})
})
@@ -234,6 +337,7 @@ describe('fetchSecurity', () => {
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/security',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.security).toEqual(security)
})
@@ -273,11 +377,11 @@ describe('saveSecurity', () => {
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/security',
{
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ security }),
},
}),
)
})
@@ -302,3 +406,60 @@ describe('saveSecurity', () => {
).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 }),
}),
)
})
})