Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
246 lines
8.4 KiB
TypeScript
246 lines
8.4 KiB
TypeScript
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeEach,
|
|
afterEach,
|
|
type MockInstance
|
|
} from 'vitest';
|
|
import {
|
|
listAdminUsers,
|
|
deleteAdminUser,
|
|
setUserAdmin,
|
|
createAdminUser,
|
|
listAdminMangas,
|
|
listAdminChapters,
|
|
getSystemStats
|
|
} from './admin';
|
|
|
|
function ok(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
function noContent(): Response {
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
function envelope(status: number, code: string, message: string): Response {
|
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
const userFixture = {
|
|
id: 'u-1',
|
|
username: 'alice',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
is_admin: false
|
|
};
|
|
|
|
const mangaFixture = {
|
|
id: 'm-1',
|
|
title: 'Test',
|
|
status: 'ongoing',
|
|
cover_image_path: null,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
sync_state: 'synced' as const,
|
|
chapter_count: 3,
|
|
latest_seen_at: '2026-01-02T00:00:00Z'
|
|
};
|
|
|
|
const systemFixture = {
|
|
disk: {
|
|
total_bytes: 1_000_000,
|
|
used_bytes: 500_000,
|
|
free_bytes: 500_000,
|
|
percent_used: 50.0
|
|
},
|
|
memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 },
|
|
cpu: { percent_used: 12.3 },
|
|
alerts: []
|
|
};
|
|
|
|
describe('admin api client', () => {
|
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ---- users ----
|
|
|
|
it('listAdminUsers GETs /v1/admin/users and parses the paged envelope', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ items: [userFixture], page: { limit: 50, offset: 0, total: 1 } })
|
|
);
|
|
const page = await listAdminUsers({ limit: 50 });
|
|
expect(page.items).toHaveLength(1);
|
|
expect(page.items[0]).toEqual(userFixture);
|
|
expect(page.page.total).toBe(1);
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/admin\/users\?limit=50$/);
|
|
});
|
|
|
|
it('listAdminUsers forwards search + offset query params', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ items: [], page: { limit: 50, offset: 10, total: 0 } })
|
|
);
|
|
await listAdminUsers({ search: 'al', offset: 10 });
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toContain('search=al');
|
|
expect(url).toContain('offset=10');
|
|
});
|
|
|
|
it('listAdminUsers surfaces 403 forbidden via ApiError.code', async () => {
|
|
fetchSpy.mockResolvedValueOnce(envelope(403, 'forbidden', 'forbidden'));
|
|
await expect(listAdminUsers()).rejects.toMatchObject({
|
|
status: 403,
|
|
code: 'forbidden'
|
|
});
|
|
});
|
|
|
|
it('deleteAdminUser DELETEs to /v1/admin/users/{id} and handles 204', async () => {
|
|
fetchSpy.mockResolvedValueOnce(noContent());
|
|
await expect(deleteAdminUser('u-1')).resolves.toBeUndefined();
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/admin\/users\/u-1$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('DELETE');
|
|
});
|
|
|
|
it('deleteAdminUser surfaces 409 conflict (self-delete / last-admin)', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
envelope(409, 'conflict', 'cannot delete yourself; ask another admin')
|
|
);
|
|
await expect(deleteAdminUser('u-1')).rejects.toMatchObject({
|
|
status: 409,
|
|
code: 'conflict'
|
|
});
|
|
});
|
|
|
|
it('createAdminUser POSTs to /v1/admin/users with body and returns the created user', async () => {
|
|
const created = { ...userFixture, username: 'invited01' };
|
|
fetchSpy.mockResolvedValueOnce(ok(created, 201));
|
|
const got = await createAdminUser({
|
|
username: 'invited01',
|
|
password: 'freshpass1234'
|
|
});
|
|
expect(got).toEqual(created);
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/admin\/users$/);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('POST');
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
username: 'invited01',
|
|
password: 'freshpass1234'
|
|
});
|
|
});
|
|
|
|
it('createAdminUser forwards is_admin when provided', async () => {
|
|
const created = { ...userFixture, username: 'coadmin', is_admin: true };
|
|
fetchSpy.mockResolvedValueOnce(ok(created, 201));
|
|
await createAdminUser({
|
|
username: 'coadmin',
|
|
password: 'freshpass1234',
|
|
is_admin: true
|
|
});
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
username: 'coadmin',
|
|
password: 'freshpass1234',
|
|
is_admin: true
|
|
});
|
|
});
|
|
|
|
it('createAdminUser surfaces 409 conflict on duplicate username', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
envelope(409, 'conflict', 'username is already taken')
|
|
);
|
|
await expect(
|
|
createAdminUser({ username: 'taken', password: 'freshpass1234' })
|
|
).rejects.toMatchObject({ status: 409, code: 'conflict' });
|
|
});
|
|
|
|
it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
|
|
const updated = { ...userFixture, is_admin: true };
|
|
fetchSpy.mockResolvedValueOnce(ok(updated));
|
|
const got = await setUserAdmin('u-1', true);
|
|
expect(got).toEqual(updated);
|
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('PATCH');
|
|
expect(JSON.parse(init.body as string)).toEqual({ is_admin: true });
|
|
});
|
|
|
|
// ---- mangas + chapters ----
|
|
|
|
it('listAdminMangas GETs /v1/admin/mangas and forwards sync_state filter', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ items: [mangaFixture], page: { limit: 100, offset: 0, total: 1 } })
|
|
);
|
|
const page = await listAdminMangas({ syncState: 'in_progress', limit: 100 });
|
|
expect(page.items[0].sync_state).toBe('synced');
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toContain('sync_state=in_progress');
|
|
expect(url).toContain('limit=100');
|
|
});
|
|
|
|
it('listAdminChapters GETs the nested chapter route and parses the paged envelope', async () => {
|
|
const chapter = {
|
|
id: 'c-1',
|
|
manga_id: 'm-1',
|
|
number: 1,
|
|
title: null,
|
|
page_count: 12,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
sync_state: 'synced' as const,
|
|
latest_seen_at: null
|
|
};
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ items: [chapter], page: { limit: 200, offset: 0, total: 1 } })
|
|
);
|
|
const resp = await listAdminChapters('m-1');
|
|
expect(resp.items).toEqual([chapter]);
|
|
expect(resp.page.total).toBe(1);
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
|
|
});
|
|
|
|
it('listAdminChapters forwards limit + offset query params', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({ items: [], page: { limit: 50, offset: 100, total: 0 } })
|
|
);
|
|
await listAdminChapters('m-1', { limit: 50, offset: 100 });
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toContain('limit=50');
|
|
expect(url).toContain('offset=100');
|
|
});
|
|
|
|
// ---- system ----
|
|
|
|
it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok(systemFixture));
|
|
const s = await getSystemStats();
|
|
expect(s.disk?.percent_used).toBe(50);
|
|
expect(s.memory.percent_used).toBe(50);
|
|
expect(s.cpu.percent_used).toBe(12.3);
|
|
expect(s.alerts).toEqual([]);
|
|
const url = fetchSpy.mock.calls[0][0] as string;
|
|
expect(url).toMatch(/\/v1\/admin\/system$/);
|
|
});
|
|
|
|
it('getSystemStats keeps disk null when backend reports a non-local store', async () => {
|
|
fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, disk: null }));
|
|
const s = await getSystemStats();
|
|
expect(s.disk).toBeNull();
|
|
});
|
|
});
|