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:
+179
-18
@@ -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 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user