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.
193 lines
7.2 KiB
TypeScript
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');
|
|
});
|
|
});
|