Files
Mangalord/frontend/src/lib/api/auth.test.ts
MechaCat02 2f47faa11c feat(auth): ALLOW_SELF_REGISTER toggle + public /auth/config endpoint (0.42.0)
Lets operators run a closed-membership deployment by setting
ALLOW_SELF_REGISTER=false (default true, so existing deploys are
unaffected). When off, POST /auth/register returns 403 forbidden. The
rate-limit token is consumed BEFORE the disabled check so the timing
doesn't distinguish enabled-but-rejected from disabled — closes the
toggle-state probe channel.

New public GET /auth/config returns { self_register_enabled: bool }
so the frontend can render its register affordances correctly
without conflating "disabled" with "rate-limited" (which a probe
attempt would).

Frontend: a lightweight reactive `authConfig` store loads the flag
once on root-layout mount (and again on /register direct navigation,
which bypasses the layout's onMount). Header hides the Register link
when the toggle is off; /register renders a "self-registration is
disabled — ask an administrator" notice instead of the form.

Admin-create endpoint that pairs with this toggle is intentionally
not in this PR — it lands as the next branch (feat/admin-user-create).
The toggle alone is independently useful for deployments that want
to lock down enrollment without yet wiring an admin UI.
2026-05-31 13:56:18 +02:00

193 lines
7.2 KiB
TypeScript

import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance
} from 'vitest';
import {
register,
login,
logout,
me,
changePassword,
createToken,
deleteToken,
getAuthConfig
} from './auth';
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: 'user-1',
username: 'alice',
created_at: '2026-01-01T00:00:00Z'
};
describe('auth api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('register POSTs JSON to /v1/auth/register and returns the user', async () => {
fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }, 201));
const user = await register({ username: 'alice', password: 'hunter2hunter2' });
expect(user).toEqual(userFixture);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/register$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({
username: 'alice',
password: 'hunter2hunter2'
});
});
it('register surfaces 409 conflict via ApiError.code', async () => {
fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'username is already taken'));
await expect(
register({ username: 'alice', password: 'hunter2hunter2' })
).rejects.toMatchObject({ status: 409, code: 'conflict' });
});
it('login POSTs JSON to /v1/auth/login and returns the user', async () => {
fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }));
const user = await login({ username: 'alice', password: 'hunter2hunter2' });
expect(user).toEqual(userFixture);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/login$/);
});
it('login surfaces 401 unauthenticated via ApiError.code', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
await expect(
login({ username: 'alice', password: 'wrong' })
).rejects.toMatchObject({ status: 401, code: 'unauthenticated' });
});
it('logout POSTs to /v1/auth/logout and handles 204', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await expect(logout()).resolves.toBeUndefined();
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/logout$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
// Consistent content-type for all mutation requests, matching
// the rest of the module — axum doesn't require it but the
// header keeps the request style uniform.
const headers = new Headers(init.headers);
expect(headers.get('content-type')).toBe('application/json');
});
it('me returns the user on 200', async () => {
fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }));
await expect(me()).resolves.toEqual(userFixture);
});
it('me returns null on 401 (anonymous user)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
await expect(me()).resolves.toBeNull();
});
it('me re-throws non-401 errors', async () => {
fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'internal error'));
await expect(me()).rejects.toMatchObject({ status: 500 });
});
it('changePassword PATCHes /v1/auth/me/password and handles 204', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await expect(
changePassword({
current_password: 'hunter2hunter2',
new_password: 'freshpassfreshpass'
})
).resolves.toBeUndefined();
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/me\/password$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body as string)).toEqual({
current_password: 'hunter2hunter2',
new_password: 'freshpassfreshpass'
});
});
it('changePassword surfaces 401 (wrong current) via ApiError', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
await expect(
changePassword({ current_password: 'wrong', new_password: 'freshpassfreshpass' })
).rejects.toMatchObject({ status: 401, code: 'unauthenticated' });
});
it('changePassword surfaces 400 (weak new) via ApiError', async () => {
fetchSpy.mockResolvedValueOnce(envelope(400, 'invalid_input', 'password must be at least 8 characters'));
await expect(
changePassword({ current_password: 'hunter2hunter2', new_password: 'short' })
).rejects.toMatchObject({ status: 400, code: 'invalid_input' });
});
it('createToken POSTs to /v1/auth/tokens and returns CreatedToken with bearer', async () => {
fetchSpy.mockResolvedValueOnce(
ok(
{
id: 't1',
user_id: 'user-1',
name: 'ci-bot',
created_at: '2026-01-01T00:00:00Z',
last_used_at: null,
bearer: 'raw-token-abc'
},
201
)
);
const t = await createToken('ci-bot');
expect(t.name).toBe('ci-bot');
expect(t.bearer).toBe('raw-token-abc');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/tokens$/);
});
it('getAuthConfig GETs /v1/auth/config and parses the flag', async () => {
fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false }));
const cfg = await getAuthConfig();
expect(cfg.self_register_enabled).toBe(false);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/config$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
// Public endpoint; no method override means default GET.
expect(init?.method ?? 'GET').toBe('GET');
});
it('deleteToken DELETEs to /v1/auth/tokens/{id} and handles 204', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await expect(deleteToken('t1')).resolves.toBeUndefined();
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/auth\/tokens\/t1$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('DELETE');
});
});