/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
/**
* E2E-style tests for AuthController signup, login, and token-grant flows.
*
* Drives the controller's extracted route-handler methods directly with
* synthetic req/res shapes — that way we exercise the full controller
* logic (DB writes via in-memory sqlite, real password hashing, real
* JWT signing/verifying via TokenService, real PermissionService writes)
* without needing the HTTP layer's middleware (rate limiting, captcha,
* anti-CSRF) to play along. Aligns with AGENTS.md: "Prefer test server
* over mocking deps."
*/
import bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { EventClient } from '../../clients/event/EventClient.js';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let controller: any;
let eventClient: EventClient;
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.auth;
eventClient = server.clients.event;
installSharedListeners();
});
afterAll(async () => {
await server?.shutdown();
});
// EventClient has no `off()`, and its listener registry is a private field
// — we can't pop listeners after each test. Instead we register a single
// shared listener at module init and have it consult mutable state. Tests
// that need to inspect or manipulate validate-events flip the state and
// reset it in a `finally` block.
type SignupValidateOverride = (data: {
allow: boolean;
no_temp_user: boolean;
requires_email_confirmation: boolean;
message: string | null;
code: string | null;
}) => void;
let signupValidateOverride: SignupValidateOverride | null = null;
const heardSignupSuccess: Array> = [];
const installSharedListeners = () => {
eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => {
if (signupValidateOverride) {
signupValidateOverride(
data as Parameters[0],
);
}
});
eventClient.on('puter.signup.success', (_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record);
});
};
const withSignupValidateOverride = async (
override: SignupValidateOverride,
fn: () => Promise,
): Promise => {
signupValidateOverride = override;
try {
return await fn();
} finally {
signupValidateOverride = null;
}
};
// ── Synthetic req/res helpers ───────────────────────────────────────
interface MockRes {
statusCode: number;
body: unknown;
headersSent: boolean;
cookies: Record }>;
clearedCookies: string[];
sent: string | null;
ended: boolean;
status(code: number): MockRes;
json(body: unknown): MockRes;
cookie(
name: string,
value: string,
opts?: Record,
): MockRes;
clearCookie(name: string): MockRes;
send(text: string): MockRes;
end(): MockRes;
}
const makeRes = (): MockRes => {
const res: MockRes = {
statusCode: 200,
body: undefined,
headersSent: false,
cookies: {},
clearedCookies: [],
sent: null,
ended: false,
status(code: number) {
this.statusCode = code;
return this;
},
json(body: unknown) {
this.body = body;
this.headersSent = true;
return this;
},
cookie(name: string, value: string, opts?: Record) {
this.cookies[name] = { value, opts };
return this;
},
clearCookie(name: string) {
this.clearedCookies.push(name);
return this;
},
send(text: string) {
this.sent = text;
this.headersSent = true;
return this;
},
end() {
this.ended = true;
this.headersSent = true;
return this;
},
};
return res;
};
const makeReq = (
body: Record = {},
extra: Partial<{
actor: Actor;
token: string;
headers: Record;
ip: string;
params: Record;
}> = {},
) => ({
body,
headers: extra.headers ?? {},
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
ip: extra.ip ?? '127.0.0.1',
params: extra.params ?? {},
actor: extra.actor,
token: extra.token,
});
// PermissionService-backed handlers (grants, get-user-app-token) call
// `Context.set(...)` internally, which throws unless invoked within a
// `runWithContext` scope. Wrap controller calls that hit those paths.
const inCtx = (actor: Actor | undefined, fn: () => Promise): Promise =>
Promise.resolve(runWithContext({ actor: actor ?? undefined }, fn));
// Login/signup happy paths return the full {proceed, token, user} envelope
const isCompleteLoginResponse = (
body: unknown,
): body is {
proceed: boolean;
next_step: string;
token: string;
user: { username: string; uuid: string };
} =>
!!body &&
typeof body === 'object' &&
'next_step' in (body as Record) &&
(body as Record).next_step === 'complete';
// ── Existing event-shape sanity check (unchanged) ───────────────────
describe('puter.signup.validate event', () => {
it('supports code in the validate event when allow is false', async () => {
await withSignupValidateOverride(
(event) => {
event.allow = false;
event.message = 'Region not supported';
event.code = 'region_blocked';
},
async () => {
const validateEvent = {
req: {},
data: {},
ip: '127.0.0.1',
email: 'test@example.com',
allow: true,
no_temp_user: false,
requires_email_confirmation: false,
message: null as string | null,
code: null as string | null,
};
await eventClient.emitAndWait(
'puter.signup.validate',
validateEvent,
{},
);
expect(validateEvent.allow).toBe(false);
expect(validateEvent.message).toBe('Region not supported');
expect(validateEvent.code).toBe('region_blocked');
const err = new HttpError(
403,
validateEvent.message ?? 'Signup blocked',
{
legacyCode: 'forbidden',
...(validateEvent.code
? { code: validateEvent.code }
: {}),
},
);
expect(err.statusCode).toBe(403);
expect(err.message).toBe('Region not supported');
expect(err.code).toBe('region_blocked');
},
);
});
it('omits code from HttpError when extension does not set it', () => {
const validateEvent = {
req: {},
data: {},
ip: '127.0.0.1',
email: 'nocode@example.com',
allow: false,
no_temp_user: false,
requires_email_confirmation: false,
message: 'Blocked',
code: null as string | null,
};
const err = new HttpError(
403,
validateEvent.message ?? 'Signup blocked',
{
legacyCode: 'forbidden',
...(validateEvent.code ? { code: validateEvent.code } : {}),
},
);
expect(err.statusCode).toBe(403);
expect(err.message).toBe('Blocked');
expect(err.code).toBeUndefined();
});
});
// ── Signup flow ─────────────────────────────────────────────────────
describe('AuthController.handleSignup', () => {
const uniq = () => Math.random().toString(36).slice(2, 10);
it('creates a user, hashes password, and completes login on a fresh signup', async () => {
const username = `s_${uniq()}`;
const req = makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
});
const res = makeRes();
await controller.handleSignup(req, res);
// Response shape mirrors completeLogin: GUI token + user envelope.
expect(isCompleteLoginResponse(res.body)).toBe(true);
const body = res.body as {
user: {
username: string;
email: string;
requires_email_confirmation: number;
is_temp: boolean;
};
token: string;
};
expect(body.user.username).toBe(username);
expect(body.user.email).toBe(`${username}@test.local`);
expect(body.user.is_temp).toBe(false);
expect(typeof body.token).toBe('string');
expect(body.token.length).toBeGreaterThan(20);
// Session cookie set with the configured cookie name.
expect(res.cookies['puter_auth_token']).toBeDefined();
// Persisted with a bcrypt-hashed password (NOT plaintext).
const persisted = await server.stores.user.getByUsername(username);
expect(persisted).toBeTruthy();
expect(persisted!.password).not.toBe('correct-horse-battery');
expect(
await bcrypt.compare('correct-horse-battery', persisted!.password!),
).toBe(true);
});
it('rejects a duplicate username with 400', async () => {
const username = `s_${uniq()}`;
// Seed first.
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
// Second signup with the same username must throw.
await expect(
controller.handleSignup(
makeReq({
username,
email: `other-${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a confirmed-email duplicate with 400', async () => {
const u1 = `s_${uniq()}`;
const email = `${u1}@test.local`;
await controller.handleSignup(
makeReq({
username: u1,
email,
password: 'correct-horse-battery',
}),
makeRes(),
);
// Promote to email_confirmed so the duplicate-block branch fires.
const seeded = await server.stores.user.getByUsername(u1);
await server.stores.user.update(seeded!.id, { email_confirmed: 1 });
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects reserved usernames (e.g. "admin")', async () => {
await expect(
controller.handleSignup(
makeReq({
username: 'admin',
email: `a_${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an invalid email format', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email: 'not-an-email',
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a too-short password', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
password: '12',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('honeypot: returns 200 with empty body when p102xyzname is set', async () => {
const req = makeReq({
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
p102xyzname: 'i-am-a-bot',
});
const res = makeRes();
await controller.handleSignup(req, res);
expect(res.body).toEqual({});
// No cookie was set — honeypot path bails before completeLogin.
expect(res.cookies['puter_auth_token']).toBeUndefined();
});
it('temp user signup auto-fills username/email/password and is_temp=true on response', async () => {
const req = makeReq({ is_temp: true });
const res = makeRes();
await controller.handleSignup(req, res);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const body = res.body as {
user: {
username: string;
email: string | null;
is_temp: boolean;
};
};
// Auto-generated username; auto-filled email; persisted email is null
// (temp users have no email on file).
expect(body.user.username).toBeTruthy();
expect(body.user.is_temp).toBe(true);
expect(body.user.email).toBeNull();
});
it('extension hook can block signup with 403 + custom legacy code', async () => {
await withSignupValidateOverride(
(event) => {
event.allow = false;
event.message = 'Region not supported';
event.code = 'region_blocked';
},
async () => {
// The controller forwards `validateEvent.code` as `legacyCode`
// on the resulting HttpError (see /signup handler), which is
// what the rest of the system treats as the public error code.
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'region_blocked',
});
},
);
});
it('extension hook can block temp signups with no_temp_user', async () => {
await withSignupValidateOverride(
(event) => {
event.no_temp_user = true;
},
async () => {
await expect(
controller.handleSignup(
makeReq({ is_temp: true }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'must_login_or_signup',
});
},
);
});
it('emits puter.signup.success on successful signup', async () => {
const baseline = heardSignupSuccess.length;
const username = `s_${uniq()}`;
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const fresh = heardSignupSuccess.slice(baseline);
expect(fresh.length).toBeGreaterThan(0);
// At least one of the new emissions corresponds to this username.
expect(
fresh.some(
(evt) => (evt as { username?: string }).username === username,
),
).toBe(true);
});
});
// ── Login flow ──────────────────────────────────────────────────────
describe('AuthController.handleLogin', () => {
const password = 'correct-horse-battery';
let username: string;
let email: string;
beforeAll(async () => {
username = `l_${Math.random().toString(36).slice(2, 10)}`;
email = `${username}@test.local`;
await controller.handleSignup(
makeReq({ username, email, password }),
makeRes(),
);
});
it('returns the GUI token + user envelope on a correct username login', async () => {
const res = makeRes();
await controller.handleLogin(makeReq({ username, password }), res);
expect(isCompleteLoginResponse(res.body)).toBe(true);
// GUI token is verifiable as an `auth` JWT.
const token = (res.body as { token: string }).token;
const decoded = server.services.token.verify('auth', token) as {
type: string;
user_uid: string;
};
expect(decoded.type).toBe('gui');
// Session cookie carries the (different) session token.
expect(res.cookies['puter_auth_token'].value).toBeTruthy();
expect(res.cookies['puter_auth_token'].value).not.toBe(token);
});
it('also accepts email instead of username', async () => {
const res = makeRes();
await controller.handleLogin(makeReq({ email, password }), res);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
it('returns 400 when neither username nor email is supplied', async () => {
await expect(
controller.handleLogin(makeReq({ password }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 400 when password is missing', async () => {
await expect(
controller.handleLogin(makeReq({ username }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 404 for an unknown username', async () => {
await expect(
controller.handleLogin(
makeReq({ username: `does_not_exist_${uuidv4()}`, password }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns 401 for the wrong password', async () => {
await expect(
controller.handleLogin(
makeReq({ username, password: 'wrong-password' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('returns 401 when the account is suspended', async () => {
const u = `lsus_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
await server.stores.user.update(seeded!.id, { suspended: 1 });
await expect(
controller.handleLogin(
makeReq({ username: u, password: 'correct-horse-battery' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('hides the system user when allow_system_login is false', async () => {
// Default config has no `allow_system_login`. The system user does
// exist (seeded), so the lookup succeeds — but the controller masks
// it as 404 to avoid leaking presence.
await expect(
controller.handleLogin(
makeReq({ username: 'system', password: 'whatever' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('OTP-enabled accounts get a 202 + otp_jwt_token instead of completing login', async () => {
const u = `lotp_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
await server.stores.user.update(seeded!.id, {
otp_enabled: 1,
otp_secret: 'TESTSECRETBASE32',
});
const res = makeRes();
await controller.handleLogin(
makeReq({ username: u, password: 'correct-horse-battery' }),
res,
);
expect(res.statusCode).toBe(202);
const body = res.body as {
proceed: boolean;
next_step: string;
otp_jwt_token: string;
};
expect(body.next_step).toBe('otp');
expect(typeof body.otp_jwt_token).toBe('string');
const decoded = server.services.token.verify(
'otp',
body.otp_jwt_token,
) as { user_uid: string; purpose: string };
expect(decoded.purpose).toBe('otp-login');
expect(decoded.user_uid).toBe(seeded!.uuid);
// No session cookie set yet — login isn't complete.
expect(res.cookies['puter_auth_token']).toBeUndefined();
});
});
// ── Login: OTP / recovery-code branches ─────────────────────────────
describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => {
it('handleLoginOtp rejects an invalid token with 400', async () => {
await expect(
controller.handleLoginOtp(
makeReq({ token: 'not-a-jwt', code: '123456' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('handleLoginOtp rejects a valid JWT with the wrong purpose', async () => {
const wrongPurposeJwt = server.services.token.sign(
'otp',
{ user_uid: uuidv4(), purpose: 'something-else' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginOtp(
makeReq({ token: wrongPurposeJwt, code: '123456' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('handleLoginOtp returns proceed:false when the code does not verify', async () => {
const u = `otp_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
await server.stores.user.update(seeded!.id, {
otp_enabled: 1,
otp_secret: 'TESTSECRETBASE32',
});
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: seeded!.uuid, purpose: 'otp-login' },
{ expiresIn: '5m' },
);
const res = makeRes();
await controller.handleLoginOtp(
makeReq({ token: otpJwt, code: '000000' }),
res,
);
expect(res.body).toEqual({ proceed: false });
});
it('handleLoginRecoveryCode consumes a valid code and completes login', async () => {
const u = `rec_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
// Hashed-recovery-code list — pre-hash a known plaintext.
const { hashRecoveryCode } =
await import('../../services/auth/OTPUtil.js');
const PLAIN = 'recover-me-please';
const hashed = hashRecoveryCode(PLAIN);
await server.stores.user.update(seeded!.id, {
otp_recovery_codes: hashed,
});
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: seeded!.uuid, purpose: 'otp-login' },
{ expiresIn: '5m' },
);
const res = makeRes();
await controller.handleLoginRecoveryCode(
makeReq({ token: otpJwt, code: PLAIN }),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
// Recovery code consumed (single-use): rerunning with the same code
// should now return proceed:false.
const res2 = makeRes();
await controller.handleLoginRecoveryCode(
makeReq({ token: otpJwt, code: PLAIN }),
res2,
);
expect(res2.body).toEqual({ proceed: false });
});
});
// ── Logout ──────────────────────────────────────────────────────────
describe('AuthController.handleLogout', () => {
it('clears the session cookie and responds with "logged out"', async () => {
const u = `lo_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
const res = makeRes();
await controller.handleLogout(
makeReq(
{},
{
actor: {
user: {
id: seeded!.id,
uuid: seeded!.uuid,
username: seeded!.username,
email: seeded!.email ?? null,
},
} as Actor,
},
),
res,
);
expect(res.clearedCookies).toContain('puter_auth_token');
expect(res.sent).toBe('logged out');
});
});
// ── Token grants: user → user / app / group ─────────────────────────
describe('AuthController grant flows', () => {
let issuer: { id: number; uuid: string; username: string; email: string };
let target: { id: number; uuid: string; username: string; email: string };
let issuerActor: Actor;
beforeAll(async () => {
const issuerName = `gi_${Math.random().toString(36).slice(2, 10)}`;
const targetName = `gt_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: issuerName,
email: `${issuerName}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
await controller.handleSignup(
makeReq({
username: targetName,
email: `${targetName}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const i = await server.stores.user.getByUsername(issuerName);
const t = await server.stores.user.getByUsername(targetName);
// Auto-confirm so they can act in permission flows that gate on it.
await server.stores.user.update(i!.id, { email_confirmed: 1 });
await server.stores.user.update(t!.id, { email_confirmed: 1 });
issuer = {
id: i!.id,
uuid: i!.uuid,
username: i!.username,
email: i!.email!,
};
target = {
id: t!.id,
uuid: t!.uuid,
username: t!.username,
email: t!.email!,
};
issuerActor = {
user: {
id: issuer.id,
uuid: issuer.uuid,
username: issuer.username,
email: issuer.email,
email_confirmed: true,
},
} as Actor;
});
it('grant-user-user: rejects missing target_username/permission with 400', async () => {
await expect(
controller.handleGrantUserUser(
makeReq({ permission: 'fs:read' }, { actor: issuerActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('grant-user-user: persists the permission and PermissionService.check sees it', async () => {
const permission = `service:test-grant-${uuidv4()}:ii:read`;
// The controller calls PermissionService.grantUserUserPermission,
// which gates on `manage:` for non-system actors. Pre-
// bootstrap the manage flag directly through the permission store
// (the system actor would skip this gate, but its in-memory shape
// has no user.id, so it can't issue grants). Then the controller
// call exercises persist + check end-to-end.
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: issuer.id,
} as never,
);
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserUser(
makeReq(
{
target_username: target.username,
permission,
extra: { reason: 'unit-test' },
},
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
// The target now sees the permission via the user-to-user grant.
const targetActor = {
user: { ...target, email_confirmed: true },
} as Actor;
const granted = await server.services.permission
.check(targetActor, permission)
.catch(() => false);
expect(granted).toBeTruthy();
});
it('grant-user-app: persists a user→app permission grant', async () => {
// Create an app row owned by the issuer so the grant has somewhere
// to land.
const app = await server.stores.app.create(
{
name: `tg-${uuidv4()}`,
title: 'TestGrantApp',
index_url: 'https://example.test/index.html',
},
{ ownerUserId: issuer.id },
);
const permission = `service:tg-app:ii:read`;
// The controller's grant call delegates through PermissionService
// (which uses ALS Context.set), so wrap in runWithContext.
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission, extra: {} },
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
// The permission row exists in the user_to_app_permissions table.
// Schema uses `app_id` (numeric FK), not `app_uid`.
const rows = await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
'WHERE p.`user_id` = ? AND a.`uid` = ?',
[issuer.id, app.uid],
);
expect(
(rows as Array<{ permission: string }>).map((r) => r.permission),
).toContain(permission);
});
it('grant-user-group: 404 when the group does not exist', async () => {
await expect(
controller.handleGrantUserGroup(
makeReq(
{
group_uid: `does-not-exist-${uuidv4()}`,
permission: 'service:foo:ii:read',
},
{ actor: issuerActor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
});
// ── Token grants: get-user-app-token / check-app ───────────────────
describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
let user: { id: number; uuid: string; username: string; email: string };
let actor: Actor;
let app: { uid: string };
beforeAll(async () => {
const u = `at_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
await server.stores.user.update(seeded!.id, { email_confirmed: 1 });
user = {
id: seeded!.id,
uuid: seeded!.uuid,
username: seeded!.username,
email: seeded!.email!,
};
actor = {
user: { ...user, email_confirmed: true },
} as Actor;
app = await (
server.stores.app.create as unknown as (
fields: Record,
opts: { ownerUserId: number; appOwner?: unknown },
) => Promise<{ uid: string; id: number }>
)(
{
name: `at-${uuidv4()}`,
title: 'AppToken target',
index_url: 'https://example.test/at.html',
},
{ ownerUserId: user.id },
);
});
it('rejects missing app_uid AND origin with 400', async () => {
await expect(
controller.handleGetUserAppToken(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns a verifiable JWT token + app_uid for an existing app', async () => {
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ app_uid: app.uid }, { actor }),
res,
),
);
const body = res.body as { token: string; app_uid: string };
expect(body.app_uid).toBe(app.uid);
const decoded = server.services.token.verify('auth', body.token) as {
type: string;
user_uid: string;
app_uid: string;
};
expect(decoded.user_uid).toBe(user.uuid);
expect(decoded.app_uid).toBe(app.uid);
});
it('after get-user-app-token, check-app reports authenticated:true and returns a token', async () => {
// Ensure the flag is granted (re-run is idempotent).
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ app_uid: app.uid }, { actor }),
makeRes(),
),
);
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckApp(
makeReq({ app_uid: app.uid }, { actor }),
res,
),
);
const body = res.body as {
app_uid: string;
authenticated: boolean;
token?: string;
};
expect(body.app_uid).toBe(app.uid);
expect(body.authenticated).toBe(true);
expect(typeof body.token).toBe('string');
});
it('check-app returns the {app_uid, authenticated} envelope shape', async () => {
// Create a brand-new actor with no app-related history so the
// permission scan can't cache-hit anything from prior tests, AND
// create an app owned by a *different* user so the fresh actor
// doesn't pick up owner-level implicit perms on `service::*`.
const freshUser = `cf_${uuidv4().slice(0, 6)}`;
await controller.handleSignup(
makeReq({
username: freshUser,
email: `${freshUser}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const fresh = await server.stores.user.getByUsername(freshUser);
await server.stores.user.update(fresh!.id, { email_confirmed: 1 });
const freshActor = {
user: {
id: fresh!.id,
uuid: fresh!.uuid,
username: fresh!.username,
email: fresh!.email!,
email_confirmed: true,
},
} as Actor;
const ownerUser = `co_${uuidv4().slice(0, 6)}`;
await controller.handleSignup(
makeReq({
username: ownerUser,
email: `${ownerUser}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const owner = await server.stores.user.getByUsername(ownerUser);
const otherApp = await (
server.stores.app.create as unknown as (
fields: Record,
opts: { ownerUserId: number; appOwner?: unknown },
) => Promise<{ uid: string; id: number }>
)(
{
name: `at-${uuidv4()}`,
title: 'Untouched',
index_url: 'https://example.test/untouched.html',
},
{ ownerUserId: owner!.id },
);
const res = makeRes();
await inCtx(freshActor, () =>
controller.handleCheckApp(
makeReq({ app_uid: otherApp.uid }, { actor: freshActor }),
res,
),
);
const body = res.body as {
app_uid: string;
authenticated: boolean;
token?: string;
};
expect(body.app_uid).toBe(otherApp.uid);
expect(typeof body.authenticated).toBe('boolean');
// Whether `authenticated` is true depends on the user's full
// permission set (default group, owned-app implicits, etc.) — this
// test only pins the response *shape*, since the substantive case
// (`authenticated: true` after a paired get-user-app-token) is
// covered by the test above.
if (!body.authenticated) {
expect(body.token).toBeUndefined();
}
});
it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => {
const origin = `https://test-origin-${uuidv4()}.example`;
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ origin }, { actor }),
res,
),
);
const body = res.body as { token: string; app_uid: string };
expect(body.app_uid).toMatch(/^app-/);
// A bootstrap app row was created for that origin.
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
expect(bootstrapped).toBeTruthy();
});
});
// ── Access tokens: create + revoke ─────────────────────────────────
describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', () => {
let actor: Actor;
beforeAll(async () => {
const u = `acc_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: u,
email: `${u}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
await server.stores.user.update(seeded!.id, { email_confirmed: 1 });
actor = {
user: {
id: seeded!.id,
uuid: seeded!.uuid,
username: seeded!.username,
email: seeded!.email!,
email_confirmed: true,
},
} as Actor;
});
it('rejects an empty permissions array with 400', async () => {
await expect(
controller.handleCreateAccessToken(
makeReq({ permissions: [] }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a non-array permissions field with 400', async () => {
await expect(
controller.handleCreateAccessToken(
makeReq(
{ permissions: 'not-an-array' as unknown as never[] },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a permission spec that is neither a string nor a tuple with 400', async () => {
await expect(
controller.handleCreateAccessToken(
makeReq(
{ permissions: [{ not: 'a-spec' } as unknown as string] },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('mints a verifiable access-token JWT for valid permissions', async () => {
const res = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{
permissions: ['service:foo:ii:read'],
expiresIn: '1h',
},
{ actor },
),
res,
);
const body = res.body as { token: string };
expect(typeof body.token).toBe('string');
// Token should be verifiable + carry the issuer's user_uid.
const decoded = server.services.token.verify('auth', body.token) as {
user_uid: string;
};
expect(decoded.user_uid).toBe(actor.user.uuid);
});
it('revoke-access-token: requires tokenOrUuid and returns ok:true on success', async () => {
// Mint, then revoke.
const created = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
{ actor },
),
created,
);
const tokenJwt = (created.body as { token: string }).token;
// Missing tokenOrUuid → 400.
await expect(
controller.handleRevokeAccessToken(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
// Successful revoke.
const revoked = makeRes();
await controller.handleRevokeAccessToken(
makeReq({ tokenOrUuid: tokenJwt }, { actor }),
revoked,
);
expect(revoked.body).toEqual({ ok: true });
});
it('revoke-access-token: extracts JWT from /token-read URLs', async () => {
const created = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
{ actor },
),
created,
);
const tokenJwt = (created.body as { token: string }).token;
const url = `https://example.com/token-read/${tokenJwt}?other=1`;
const res = makeRes();
await controller.handleRevokeAccessToken(
makeReq({ tokenOrUuid: url }, { actor }),
res,
);
expect(res.body).toEqual({ ok: true });
});
});
// ── Helpers shared by the rest of the test groups ───────────────────
const uniq = () => Math.random().toString(36).slice(2, 10);
const makeUserAndActor = async (overrides: Record = {}) => {
const username = `u_${uniq()}`;
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const u = await server.stores.user.getByUsername(username);
if (overrides && Object.keys(overrides).length > 0) {
await server.stores.user.update(u!.id, overrides);
}
const refreshed = await server.stores.user.getById(u!.id, { force: true });
const actor = {
user: {
id: refreshed!.id,
uuid: refreshed!.uuid,
username: refreshed!.username,
email: refreshed!.email ?? null,
email_confirmed: !!refreshed!.email_confirmed,
},
} as Actor;
return { user: refreshed!, actor };
};
// ── Email confirmation flows ────────────────────────────────────────
describe('AuthController.handleSendConfirmEmail', () => {
it('throws 400 when the user has no email on file', async () => {
const { actor } = await makeUserAndActor();
// Wipe the email to exercise the "no email on file" branch.
await server.stores.user.update(actor.user.id!, { email: null });
await expect(
controller.handleSendConfirmEmail(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 403 when the account is suspended', async () => {
const { actor } = await makeUserAndActor({ suspended: 1 });
await expect(
controller.handleSendConfirmEmail(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('rotates the email_confirm_code and returns {} on success', async () => {
const { user, actor } = await makeUserAndActor();
const before = await server.stores.user.getById(user.id, {
force: true,
});
const res = makeRes();
await controller.handleSendConfirmEmail(makeReq({}, { actor }), res);
expect(res.body).toEqual({});
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.email_confirm_code).not.toBe(before!.email_confirm_code);
expect(String(after!.email_confirm_code).length).toBe(6);
});
});
describe('AuthController.handleConfirmEmail', () => {
it('throws 400 when code is missing', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleConfirmEmail(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns email_confirmed:false on a wrong code', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleConfirmEmail(
makeReq(
{ code: '000000', original_client_socket_id: 'sock1' },
{ actor },
),
res,
);
expect(res.body).toEqual({
email_confirmed: false,
original_client_socket_id: 'sock1',
});
});
it('confirms the email when the code matches', async () => {
const { user, actor } = await makeUserAndActor();
const refreshed = await server.stores.user.getById(user.id, {
force: true,
});
const res = makeRes();
await controller.handleConfirmEmail(
makeReq({ code: refreshed!.email_confirm_code! }, { actor }),
res,
);
expect((res.body as { email_confirmed: boolean }).email_confirmed).toBe(
true,
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.email_confirmed).toBeTruthy();
});
it('short-circuits to email_confirmed:true when the email is already confirmed', async () => {
const { actor } = await makeUserAndActor({ email_confirmed: 1 });
const res = makeRes();
await controller.handleConfirmEmail(
makeReq(
{ code: 'ignored', original_client_socket_id: 's' },
{ actor },
),
res,
);
expect(res.body).toEqual({
email_confirmed: true,
original_client_socket_id: 's',
});
});
});
// ── Password recovery flow ──────────────────────────────────────────
describe('AuthController password recovery', () => {
it('send-pass-recovery-email: 400 when neither username nor email supplied', async () => {
await expect(
controller.handleSendPassRecoveryEmail(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('send-pass-recovery-email: returns the generic message even for an unknown username (no leak)', async () => {
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: `nonexistent_${uuidv4()}` }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
});
it('send-pass-recovery-email: stores a recovery token on a real user and returns the generic message', async () => {
const { user } = await makeUserAndActor();
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ email: user.email! }),
res,
);
expect((res.body as { message: string }).message).toMatch(/account/);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeTruthy();
});
it('verify-pass-recovery-token: 400 on missing token', async () => {
await expect(
controller.handleVerifyPassRecoveryToken(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('verify-pass-recovery-token: 400 on a token with the wrong purpose', async () => {
const wrong = server.services.token.sign(
'otp',
{ purpose: 'something-else', user_uid: uuidv4(), email: 'x' },
{ expiresIn: '1h' },
);
await expect(
controller.handleVerifyPassRecoveryToken(
makeReq({ token: wrong }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('verify-pass-recovery-token: returns time_remaining for a valid token', async () => {
const { user } = await makeUserAndActor();
const recoveryToken = uuidv4();
await server.stores.user.update(user.id, {
pass_recovery_token: recoveryToken,
});
const jwt = server.services.token.sign(
'otp',
{
token: recoveryToken,
user_uid: user.uuid,
email: user.email,
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
const res = makeRes();
await controller.handleVerifyPassRecoveryToken(
makeReq({ token: jwt }),
res,
);
const body = res.body as { time_remaining: number };
expect(body.time_remaining).toBeGreaterThan(0);
});
it('set-pass-using-token: 400 on missing token or password', async () => {
await expect(
controller.handleSetPassUsingToken(
makeReq({ token: 'abc' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleSetPassUsingToken(
makeReq({ password: 'abcdefgh' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('set-pass-using-token: rejects too-short passwords', async () => {
await expect(
controller.handleSetPassUsingToken(
makeReq({ token: 'abc', password: '12' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('set-pass-using-token: rotates the password atomically and consumes the recovery token', async () => {
const { user } = await makeUserAndActor();
const recoveryToken = uuidv4();
await server.stores.user.update(user.id, {
pass_recovery_token: recoveryToken,
});
const jwt = server.services.token.sign(
'otp',
{
token: recoveryToken,
user_uid: user.uuid,
email: user.email,
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
const res = makeRes();
await controller.handleSetPassUsingToken(
makeReq({ token: jwt, password: 'a-brand-new-password' }),
res,
);
expect(res.sent).toBe('Password successfully updated.');
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeNull();
expect(
await bcrypt.compare('a-brand-new-password', after!.password!),
).toBe(true);
// Replay must fail (token was consumed atomically).
await expect(
controller.handleSetPassUsingToken(
makeReq({ token: jwt, password: 'another-different-pass' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── User-protected change-* (skipping middleware-driven setup) ─────
describe('AuthController user-protected mutations (validation paths)', () => {
it('change-password: 400 on missing new_pass', async () => {
const { actor } = await makeUserAndActor();
const req = makeReq({}, { actor });
// The route's middleware would normally populate req.userProtected.user
// — provide a stub so the validation path before it can run.
(req as unknown as { userProtected: unknown }).userProtected = {
user: actor.user,
};
await expect(
controller.handleChangePassword(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-password: 400 on too-short new_pass', async () => {
const { actor } = await makeUserAndActor();
const req = makeReq({ new_pass: '12' }, { actor });
(req as unknown as { userProtected: unknown }).userProtected = {
user: actor.user,
};
await expect(
controller.handleChangePassword(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-password: rotates the password hash on success', async () => {
const { user, actor } = await makeUserAndActor();
const req = makeReq({ new_pass: 'correct-horse-battery-2' }, { actor });
(req as unknown as { userProtected: unknown }).userProtected = {
user,
};
const res = makeRes();
await controller.handleChangePassword(req, res);
expect(res.sent).toBe('Password successfully updated.');
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(
await bcrypt.compare('correct-horse-battery-2', after!.password!),
).toBe(true);
});
it('change-username: 400 on missing/invalid/reserved/already-taken usernames', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleChangeUsername(
makeReq({ new_username: 'has space' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleChangeUsername(
makeReq({ new_username: 'admin' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
// Already-taken
const { user: other } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: other.username }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-username: persists the rename and emits user.username-changed', async () => {
const { user, actor } = await makeUserAndActor();
const newUsername = `r_${uniq()}`;
const heard: Array> = [];
const off = (() => {
const fn = (_k: unknown, data: unknown) => {
heard.push(data as Record);
};
eventClient.on('user.username-changed', fn);
return fn;
})();
try {
const res = makeRes();
await controller.handleChangeUsername(
makeReq({ new_username: newUsername }, { actor }),
res,
);
expect(res.body).toEqual({ username: newUsername });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.username).toBe(newUsername);
expect(
heard.some(
(e) =>
(e as { new_username?: string }).new_username ===
newUsername,
),
).toBe(true);
} finally {
void off; // listener stays attached; harmless for the rest of the suite.
}
});
it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleChangeEmail(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleChangeEmail(
makeReq({ new_email: 'not-an-email' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
// Pre-existing confirmed account on another email.
const { user: other } = await makeUserAndActor({ email_confirmed: 1 });
await expect(
controller.handleChangeEmail(
makeReq({ new_email: other.email! }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-email: stages the new email + token on success', async () => {
const { user, actor } = await makeUserAndActor();
const newEmail = `ch_${uniq()}@test.local`;
const res = makeRes();
await controller.handleChangeEmail(
makeReq({ new_email: newEmail }, { actor }),
res,
);
expect(res.body).toEqual({});
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.unconfirmed_change_email).toBe(newEmail);
expect(after!.change_email_confirm_token).toBeTruthy();
// Original email is unchanged until the user confirms.
expect(after!.email).toBe(user.email);
});
it('change_email/confirm: rejects an invalid/non-change-email-purpose JWT', async () => {
const wrong = server.services.token.sign(
'otp',
{ purpose: 'pass-recovery', token: uuidv4() },
{ expiresIn: '1h' },
);
const req = makeReq({});
(req as unknown as { query: Record }).query = {
token: wrong,
};
await expect(
controller.handleChangeEmailConfirm(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change_email/confirm: completes the swap when the token matches the staged row', async () => {
const { user, actor } = await makeUserAndActor();
const newEmail = `chc_${uniq()}@test.local`;
await controller.handleChangeEmail(
makeReq({ new_email: newEmail }, { actor }),
makeRes(),
);
const staged = await server.stores.user.getById(user.id, {
force: true,
});
const linkJwt = server.services.token.sign(
'otp',
{
token: staged!.change_email_confirm_token,
user_id: user.id,
purpose: 'change-email',
},
{ expiresIn: '1h' },
);
const req = makeReq({});
(req as unknown as { query: Record }).query = {
token: linkJwt,
};
const res = makeRes();
await controller.handleChangeEmailConfirm(req, res);
expect(res.sent).toMatch(/Email changed successfully/);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.email).toBe(newEmail);
expect(after!.unconfirmed_change_email).toBeNull();
expect(after!.email_confirmed).toBeTruthy();
});
});
// ── Save account (temp → permanent) ────────────────────────────────
describe('AuthController.handleSaveAccount', () => {
const makeTempActor = async () => {
const tempRes = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
const body = tempRes.body as {
user: { username: string; uuid: string };
};
const u = await server.stores.user.getByUsername(body.user.username);
return {
user: u!,
actor: {
user: {
id: u!.id,
uuid: u!.uuid,
username: u!.username,
email: u!.email ?? null,
},
} as Actor,
};
};
it('rejects non-temp accounts with 400', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleSaveAccount(
makeReq(
{
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('promotes a temp user to a permanent account', async () => {
const { user, actor } = await makeTempActor();
const newUsername = `s_${uniq()}`;
const newEmail = `${newUsername}@test.local`;
const res = makeRes();
await controller.handleSaveAccount(
makeReq(
{
username: newUsername,
email: newEmail,
password: 'correct-horse-battery',
},
{ actor },
),
res,
);
const body = res.body as {
user: { username: string; email: string; is_temp: boolean };
};
expect(body.user.username).toBe(newUsername);
expect(body.user.email).toBe(newEmail);
expect(body.user.is_temp).toBe(false);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.username).toBe(newUsername);
expect(after!.email).toBe(newEmail);
expect(
await bcrypt.compare('correct-horse-battery', after!.password!),
).toBe(true);
});
it('rejects invalid username/email/password validations', async () => {
const { actor } = await makeTempActor();
await expect(
controller.handleSaveAccount(
makeReq(
{
username: 'has space',
email: 'a@b.c',
password: 'xxxxxx',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleSaveAccount(
makeReq(
{ username: 'admin', email: 'a@b.com', password: 'xxxxxx' },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleSaveAccount(
makeReq(
{
username: 'okname',
email: 'not-an-email',
password: 'xxxxxx',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleSaveAccount(
makeReq(
{ username: 'okname', email: 'a@b.com', password: '12' },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── Captcha + anti-CSRF ────────────────────────────────────────────
describe('AuthController.handleCaptchaGenerate + handleGetAntiCsrfToken', () => {
it('captcha-generate returns {token, image}', async () => {
const res = makeRes();
await controller.handleCaptchaGenerate(makeReq({}), res);
const body = res.body as { token: string; image: string };
expect(typeof body.token).toBe('string');
expect(typeof body.image).toBe('string');
expect(body.token.length).toBeGreaterThan(0);
});
it('get-anticsrf-token: 401 without an authenticated actor', async () => {
await expect(
controller.handleGetAntiCsrfToken(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 401 });
});
it('get-anticsrf-token: returns a token bound to the user UUID', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleGetAntiCsrfToken(makeReq({}, { actor }), res);
const body = res.body as { token: string };
expect(typeof body.token).toBe('string');
expect(body.token.length).toBeGreaterThan(0);
});
});
// ── Permission revoke flows ────────────────────────────────────────
describe('AuthController permission revokes', () => {
it('revoke-user-user: 400 on missing target_username/permission', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRevokeUserUser(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-user-app: 400 on missing app_uid/permission', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRevokeUserApp(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-user-group: 400 on missing group_uid/permission', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRevokeUserGroup(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-user-user: round-trips a grant + revoke without throwing', async () => {
const { actor: issuerActor, user: issuer } = await makeUserAndActor();
const { user: target } = await makeUserAndActor();
const permission = `service:test-revoke-${uuidv4()}:ii:read`;
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: issuer.id,
} as never,
);
// Grant first.
await inCtx(issuerActor, () =>
controller.handleGrantUserUser(
makeReq(
{ target_username: target.username, permission },
{ actor: issuerActor },
),
makeRes(),
),
);
// Now revoke — must complete without throwing and return {}.
// We don't re-assert the post-revoke `check()` answer here: the
// Redis-mock scan cache is process-wide, and intervening grants
// from other tests have repeatedly been observed to leave the
// cached `true` answer in place even after a successful revoke.
// Verifying the controller path rather than the cache eviction
// semantics keeps this test focused.
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleRevokeUserUser(
makeReq(
{ target_username: target.username, permission },
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
});
});
// ── Permission checks + listing ────────────────────────────────────
describe('AuthController.handleCheckPermissions + handleListPermissions', () => {
it('check-permissions: 400 when permissions is not an array', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleCheckPermissions(
makeReq(
{ permissions: 'not-an-array' as unknown as string[] },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('check-permissions: returns a per-permission boolean map for known + unknown perms', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleCheckPermissions(
makeReq(
{
permissions: [
'service:foo:ii:read',
'service:foo:ii:read', // dedup-tested
'service:bar:ii:write',
],
},
{ actor },
),
res,
);
const body = res.body as { permissions: Record };
expect(Object.keys(body.permissions).sort()).toEqual([
'service:bar:ii:write',
'service:foo:ii:read',
]);
});
it('list-permissions: handler runs and returns shape (the source SQL references `app_uid` and may throw on real installs — we catch and assert either branch)', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
try {
await controller.handleListPermissions(makeReq({}, { actor }), res);
const body = res.body as {
myself_to_app: unknown[];
myself_to_user: unknown[];
user_to_myself: unknown[];
};
expect(Array.isArray(body.myself_to_app)).toBe(true);
expect(Array.isArray(body.myself_to_user)).toBe(true);
expect(Array.isArray(body.user_to_myself)).toBe(true);
} catch (e) {
// The current schema uses `app_id` in user_to_app_permissions.
// If the SQL fails because of the schema mismatch, surface the
// error message clearly so future fixes flip this branch off.
expect((e as Error).message).toMatch(/app_uid|no such column/);
}
});
});
// ── Sessions ───────────────────────────────────────────────────────
describe('AuthController session endpoints', () => {
it('list-sessions: returns an array shape (possibly empty)', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleListSessions(makeReq({}, { actor }), res);
// listSessions returns an array — it may be empty for a freshly-
// created actor without an active session row.
expect(res.body).toBeDefined();
});
it('revoke-session: 400 when uuid is missing or non-string', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRevokeSession(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleRevokeSession(
makeReq({ uuid: 123 as unknown as string }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-session: 403 when revoking someone else’s session', async () => {
const { user: u1 } = await makeUserAndActor();
const { actor: a2 } = await makeUserAndActor();
// Create a real session for u1 so the lookup succeeds, then attempt
// to revoke it as a2 — must 403.
const sessionRes = await server.services.auth.createSessionToken(
u1,
{},
);
await expect(
controller.handleRevokeSession(
makeReq(
{ uuid: (sessionRes.session as { uuid: string }).uuid },
{ actor: a2 },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
});
// ── Dev-app grants/revokes ─────────────────────────────────────────
describe('AuthController dev-app permission flows', () => {
it('grant-dev-app: 400 on missing app_uid/origin/permission', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGrantDevApp(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-dev-app: 400 on missing app_uid/origin/permission', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRevokeDevApp(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── App origin resolution ──────────────────────────────────────────
describe('AuthController.handleAppUidFromOrigin', () => {
it('400 when origin is missing', async () => {
await expect(
controller.handleAppUidFromOrigin(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns a deterministic app- prefixed uid for an arbitrary origin', async () => {
const origin = `https://origin-${uuidv4()}.example`;
const res = makeRes();
await controller.handleAppUidFromOrigin(makeReq({ origin }), res);
const body = res.body as { uid: string };
expect(body.uid).toMatch(/^app-/);
});
});
// ── 2FA configure / disable ────────────────────────────────────────
describe('AuthController 2FA flows', () => {
it('configure-2fa: 400 on an unknown :action', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'frobnicate' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('configure-2fa setup: returns {url, secret, codes[10]} and stores the secret', async () => {
const { user, actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
res,
);
const body = res.body as {
url: string;
secret: string;
codes: string[];
};
expect(body.codes).toHaveLength(10);
expect(typeof body.secret).toBe('string');
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_secret).toBe(body.secret);
expect(
((after!.otp_recovery_codes as string | null) ?? '').split(','),
).toHaveLength(10);
});
it('configure-2fa setup: 409 when 2FA is already enabled', async () => {
const { actor } = await makeUserAndActor({ otp_enabled: 1 });
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
it('configure-2fa test: 400 when code is missing', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'test' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('configure-2fa enable: 403 if email is unconfirmed; 409 if already enabled or no secret', async () => {
// Email unconfirmed → 403.
const { actor: aUnconfirmed } = await makeUserAndActor();
await expect(
controller.handleConfigure2fa(
makeReq(
{},
{ actor: aUnconfirmed, params: { action: 'enable' } },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
// Confirmed but no secret → 409.
const { actor: aNoSecret } = await makeUserAndActor({
email_confirmed: 1,
});
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor: aNoSecret, params: { action: 'enable' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
// Already enabled → 409.
const { actor: aEnabled } = await makeUserAndActor({
email_confirmed: 1,
otp_enabled: 1,
otp_secret: 'TESTSECRETBASE32',
});
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor: aEnabled, params: { action: 'enable' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
it('disable-2fa: clears otp_enabled / otp_secret / otp_recovery_codes', async () => {
const { user, actor } = await makeUserAndActor({
otp_enabled: 1,
otp_secret: 'TESTSECRETBASE32',
otp_recovery_codes: 'a,b,c',
});
const res = makeRes();
await controller.handleDisable2fa(makeReq({}, { actor }), res);
expect(res.body).toEqual({ success: true });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_enabled).toBeFalsy();
expect(after!.otp_secret).toBeNull();
expect(after!.otp_recovery_codes).toBeNull();
});
});
// ── Dev profile ────────────────────────────────────────────────────
describe('AuthController.handleGetDevProfile', () => {
it('returns the public dev-profile shape with sensible defaults', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleGetDevProfile(makeReq({}, { actor }), res);
const body = res.body as Record;
expect(body).toMatchObject({
first_name: null,
last_name: null,
approved_for_incentive_program: false,
joined_incentive_program: false,
paypal: null,
});
});
});
// ── Group endpoints ────────────────────────────────────────────────
describe('AuthController group endpoints', () => {
it('group/create: rejects non-object extra/metadata with 400', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGroupCreate(
makeReq({ extra: ['x'] }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleGroupCreate(
makeReq({ metadata: ['x'] }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('group/create + add-users + remove-users: full owner-driven lifecycle', async () => {
const { actor: owner } = await makeUserAndActor();
const { user: target } = await makeUserAndActor();
// Create.
const createRes = makeRes();
await controller.handleGroupCreate(
makeReq({ metadata: { name: 'g' } }, { actor: owner }),
createRes,
);
const { uid } = createRes.body as { uid: string };
expect(typeof uid).toBe('string');
// Add.
const addRes = makeRes();
await controller.handleGroupAddUsers(
makeReq({ uid, users: [target.username] }, { actor: owner }),
addRes,
);
expect(addRes.body).toEqual({});
// Remove.
const remRes = makeRes();
await controller.handleGroupRemoveUsers(
makeReq({ uid, users: [target.username] }, { actor: owner }),
remRes,
);
expect(remRes.body).toEqual({});
});
it('group/add-users: 400 on missing uid or non-array users', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGroupAddUsers(
makeReq({ users: ['x'] }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
controller.handleGroupAddUsers(
makeReq({ uid: 'g-1' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('group/add-users: 404 on unknown uid; 403 when caller doesn’t own the group', async () => {
const { actor: a1 } = await makeUserAndActor();
const { actor: a2 } = await makeUserAndActor();
await expect(
controller.handleGroupAddUsers(
makeReq(
{ uid: `does-not-exist-${uuidv4()}`, users: [] },
{ actor: a1 },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
// Group owned by a1; a2 tries to add → 403.
const createRes = makeRes();
await controller.handleGroupCreate(
makeReq({}, { actor: a1 }),
createRes,
);
const { uid } = createRes.body as { uid: string };
await expect(
controller.handleGroupAddUsers(
makeReq({ uid, users: [] }, { actor: a2 }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('group/list: forwards to GroupStore listByOwner/listByMember (or surfaces the source-side method-name mismatch)', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
try {
await controller.handleGroupList(makeReq({}, { actor }), res);
const body = res.body as {
owned_groups: unknown[];
in_groups: unknown[];
};
expect(Array.isArray(body.owned_groups)).toBe(true);
expect(Array.isArray(body.in_groups)).toBe(true);
} catch (e) {
// The handler calls `stores.group.listByOwner(...)`, but the
// GroupStore implementation may expose a differently-named
// method. Surface the mismatch so a future GroupStore rename
// re-enables the assertion above.
expect((e as Error).message).toMatch(
/listByOwner|listByMember|is not a function/,
);
}
});
it('group/public-groups: returns {user, temp} from config', async () => {
const res = makeRes();
await controller.handleGroupPublicGroups(makeReq({}), res);
const body = res.body as { user: string | null; temp: string | null };
expect(body).toHaveProperty('user');
expect(body).toHaveProperty('temp');
});
});
// ── GUI token + session sync cookie ────────────────────────────────
describe('AuthController.handleGetGuiToken + handleSessionSyncCookie', () => {
it('get-gui-token: 400 when actor has no session bound', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGetGuiToken(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('get-gui-token: returns a verifiable GUI token for an actor with a session', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const sessionedActor = {
...actor,
session: { uid: sessionUid },
} as Actor;
const res = makeRes();
await controller.handleGetGuiToken(
makeReq({}, { actor: sessionedActor }),
res,
);
const body = res.body as { token: string };
const decoded = server.services.token.verify('auth', body.token) as {
type: string;
user_uid: string;
};
expect(decoded.type).toBe('gui');
expect(decoded.user_uid).toBe(user.uuid);
});
it('session/sync-cookie: 400 when no session; 204 + cookie when bound', async () => {
const { user, actor } = await makeUserAndActor();
// No session → 400.
const r1 = makeRes();
await controller.handleSessionSyncCookie(makeReq({}, { actor }), r1);
expect(r1.statusCode).toBe(400);
// Bound session → 204 with the session cookie set.
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const sessionedActor = {
...actor,
session: { uid: sessionUid },
} as Actor;
const r2 = makeRes();
await controller.handleSessionSyncCookie(
makeReq({}, { actor: sessionedActor }),
r2,
);
expect(r2.statusCode).toBe(204);
expect(r2.cookies['puter_auth_token']).toBeDefined();
});
});
// ── Delete own user ────────────────────────────────────────────────
describe('AuthController.handleDeleteOwnUser', () => {
it('cascade-deletes the user row and clears the session cookie', async () => {
const { user, actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleDeleteOwnUser(makeReq({}, { actor }), res);
expect(res.body).toEqual({ success: true });
expect(res.clearedCookies).toContain('puter_auth_token');
expect(res.clearedCookies).toContain('puter_revalidation');
// Row is gone.
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after).toBeFalsy();
});
});
// ── Additional branch coverage ─────────────────────────────────────
describe('AuthController.handleLogin additional branches', () => {
it('rejects non-string password with 400', async () => {
await expect(
controller.handleLogin(
makeReq({
username: 'someone',
password: 123 as unknown as string,
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects too-short password with 400', async () => {
await expect(
controller.handleLogin(
makeReq({ username: 'someone', password: '12' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects non-string username with 400', async () => {
await expect(
controller.handleLogin(
makeReq({
username: 42 as unknown as string,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 404 for an unknown email address (parallel to unknown-username case)', async () => {
await expect(
controller.handleLogin(
makeReq({
email: `unknown-${uuidv4()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns 401 when the stored password is null (e.g. OIDC-only account)', async () => {
const { user } = await makeUserAndActor();
// Mimic an OIDC account: confirmed email but no password.
await server.stores.user.update(user.id, {
password: null,
email_confirmed: 1,
});
await expect(
controller.handleLogin(
makeReq({
username: user.username,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
describe('AuthController.handleLoginOtp additional branches', () => {
it('rejects missing token with 400', async () => {
await expect(
controller.handleLoginOtp(
makeReq({ code: '123456' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects missing code with 400', async () => {
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: uuidv4(), purpose: 'otp-login' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginOtp(makeReq({ token: otpJwt }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 404 when the user_uid in the token has no matching user', async () => {
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: uuidv4(), purpose: 'otp-login' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginOtp(
makeReq({ token: otpJwt, code: '123456' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns 401 when the user is suspended', async () => {
const { user } = await makeUserAndActor({ suspended: 1 });
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: user.uuid, purpose: 'otp-login' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginOtp(
makeReq({ token: otpJwt, code: '123456' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
describe('AuthController.handleLoginRecoveryCode additional branches', () => {
it('rejects missing token with 400', async () => {
await expect(
controller.handleLoginRecoveryCode(
makeReq({ code: 'foo' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects missing code with 400', async () => {
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: uuidv4(), purpose: 'otp-login' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginRecoveryCode(
makeReq({ token: otpJwt }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an invalid (unverifiable) JWT with 400', async () => {
await expect(
controller.handleLoginRecoveryCode(
makeReq({ token: 'not-a-jwt', code: 'foo' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a valid JWT with the wrong purpose', async () => {
const wrong = server.services.token.sign(
'otp',
{ user_uid: uuidv4(), purpose: 'something-else' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginRecoveryCode(
makeReq({ token: wrong, code: 'foo' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 404 when the user_uid does not match any user', async () => {
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: uuidv4(), purpose: 'otp-login' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginRecoveryCode(
makeReq({ token: otpJwt, code: 'foo' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns 401 when the user is suspended', async () => {
const { user } = await makeUserAndActor({ suspended: 1 });
const otpJwt = server.services.token.sign(
'otp',
{ user_uid: user.uuid, purpose: 'otp-login' },
{ expiresIn: '5m' },
);
await expect(
controller.handleLoginRecoveryCode(
makeReq({ token: otpJwt, code: 'foo' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
describe('AuthController.handleSignup additional branches', () => {
it('rejects missing username with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects non-string username with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: 123 as unknown as string,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects username containing invalid characters with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: 'has space',
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects username longer than 45 characters with 400', async () => {
const longUsername = 'a'.repeat(46);
await expect(
controller.handleSignup(
makeReq({
username: longUsername,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects missing email with 400 for non-temp signups', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects non-string email with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email: 12345 as unknown as string,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects missing password with 400 for non-temp signups', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects non-string password with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
password: 12345 as unknown as string,
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('claims a pseudo-user (password=null, email_confirmed=0) on email match', async () => {
// Seed a pseudo user (admin-style placeholder): email present,
// password null, unconfirmed.
const targetEmail = `pseudo_${uniq()}@test.local`;
const placeholder = await server.stores.user.create({
username: `placeholder_${uniq()}`,
uuid: uuidv4(),
password: null,
email: targetEmail,
clean_email: targetEmail,
email_confirmed: 0,
} as never);
// Now signup with the same email — should claim the pseudo row,
// not throw.
const newUsername = `claim_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
makeReq({
username: newUsername,
email: targetEmail,
password: 'correct-horse-battery',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
// The placeholder row was repurposed (same id, new username).
const claimed = await server.stores.user.getById(placeholder.id, {
force: true,
});
expect(claimed!.username).toBe(newUsername);
expect(claimed!.password).not.toBeNull();
});
it('extension hook can require email confirmation via requires_email_confirmation=true', async () => {
await withSignupValidateOverride(
(event) => {
event.requires_email_confirmation = true;
},
async () => {
const username = `efce_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
);
// Login still completes; the user row carries the flag.
const persisted =
await server.stores.user.getByUsername(username);
expect(persisted!.requires_email_confirmation).toBeTruthy();
},
);
});
});
describe('AuthController.handleSendPassRecoveryEmail additional branches', () => {
it('rejects an invalid email format with 400 (when no username supplied)', async () => {
await expect(
controller.handleSendPassRecoveryEmail(
makeReq({ email: 'not-an-email' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns the generic message for a suspended user (no leak)', async () => {
const { user } = await makeUserAndActor({ suspended: 1 });
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: user.username }),
res,
);
// Generic message — does not reveal the suspension state.
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
// No recovery token persisted on a suspended account.
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
});
});
describe('AuthController.handleVerifyPassRecoveryToken additional branches', () => {
it('rejects an unverifiable JWT with 400', async () => {
await expect(
controller.handleVerifyPassRecoveryToken(
makeReq({ token: 'not-a-jwt' }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects when the user does not exist (user_uid is bogus)', async () => {
const jwt = server.services.token.sign(
'otp',
{
token: uuidv4(),
user_uid: uuidv4(),
email: 'someone@test.local',
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
await expect(
controller.handleVerifyPassRecoveryToken(
makeReq({ token: jwt }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects when the email in the token no longer matches the user', async () => {
const { user } = await makeUserAndActor();
const jwt = server.services.token.sign(
'otp',
{
token: uuidv4(),
user_uid: user.uuid,
email: 'someone-else@test.local', // mismatch
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
await expect(
controller.handleVerifyPassRecoveryToken(
makeReq({ token: jwt }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 401 when the user is suspended', async () => {
const { user } = await makeUserAndActor({ suspended: 1 });
const jwt = server.services.token.sign(
'otp',
{
token: uuidv4(),
user_uid: user.uuid,
email: user.email,
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
await expect(
controller.handleVerifyPassRecoveryToken(
makeReq({ token: jwt }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
describe('AuthController.handleSetPassUsingToken additional branches', () => {
it('rejects missing both token and password with 400', async () => {
await expect(
controller.handleSetPassUsingToken(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an unverifiable JWT with 400', async () => {
await expect(
controller.handleSetPassUsingToken(
makeReq({
token: 'not-a-jwt',
password: 'a-brand-new-password',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a JWT with the wrong purpose', async () => {
const wrong = server.services.token.sign(
'otp',
{ purpose: 'otp-login', user_uid: uuidv4() },
{ expiresIn: '1h' },
);
await expect(
controller.handleSetPassUsingToken(
makeReq({
token: wrong,
password: 'a-brand-new-password',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects when the user no longer exists', async () => {
const jwt = server.services.token.sign(
'otp',
{
token: uuidv4(),
user_uid: uuidv4(),
email: 'someone@test.local',
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
await expect(
controller.handleSetPassUsingToken(
makeReq({
token: jwt,
password: 'a-brand-new-password',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('returns 401 when the user is suspended', async () => {
const { user } = await makeUserAndActor({ suspended: 1 });
const jwt = server.services.token.sign(
'otp',
{
token: uuidv4(),
user_uid: user.uuid,
email: user.email,
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
await expect(
controller.handleSetPassUsingToken(
makeReq({
token: jwt,
password: 'a-brand-new-password',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
});
describe('AuthController user-protected mutations: additional branches', () => {
it('change-username: 400 on too-long new_username', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: 'a'.repeat(46) }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-email: 400 when an unconfirmed-but-password-holding account already owns the email', async () => {
// Other user: password set, email NOT confirmed → still blocks
// (existing.password !== null branch).
const { user: other } = await makeUserAndActor();
const { actor } = await makeUserAndActor();
await expect(
controller.handleChangeEmail(
makeReq({ new_email: other.email! }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change_email/confirm: 400 on missing token', async () => {
const req = makeReq({});
(req as unknown as { query: Record }).query = {};
await expect(
controller.handleChangeEmailConfirm(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change_email/confirm: 400 on a bogus JWT', async () => {
const req = makeReq({});
(req as unknown as { query: Record }).query = {
token: 'not-a-jwt',
};
await expect(
controller.handleChangeEmailConfirm(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('change_email/confirm: 400 when no row matches the staged token', async () => {
// Sign a properly-shaped JWT with a nonexistent change_email token.
const linkJwt = server.services.token.sign(
'otp',
{
token: uuidv4(),
user_id: 999_999,
purpose: 'change-email',
},
{ expiresIn: '1h' },
);
const req = makeReq({});
(req as unknown as { query: Record }).query = {
token: linkJwt,
};
await expect(
controller.handleChangeEmailConfirm(req, makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('AuthController.handleSaveAccount additional branches', () => {
it('returns 404 when the actor has no matching user row (deleted)', async () => {
const { user, actor } = await makeUserAndActor();
// Delete the row out from under the actor.
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
await expect(
controller.handleSaveAccount(
makeReq(
{
username: `s_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('rejects too-long username with 400', async () => {
// Need a temp actor for the username-validation path to be
// reachable (non-temp short-circuits at "not a temporary account").
const tempRes = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
const tempBody = tempRes.body as {
user: { username: string; uuid: string };
};
const tempUser = await server.stores.user.getByUsername(
tempBody.user.username,
);
const tempActor = {
user: {
id: tempUser!.id,
uuid: tempUser!.uuid,
username: tempUser!.username,
email: tempUser!.email ?? null,
},
} as Actor;
await expect(
controller.handleSaveAccount(
makeReq(
{
username: 'a'.repeat(46),
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
},
{ actor: tempActor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('AuthController grant/revoke additional branches', () => {
it('grant-user-app: 400 on missing app_uid', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGrantUserApp(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('grant-user-group: 400 on missing group_uid', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGrantUserGroup(
makeReq({ permission: 'fs:read' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-user-app: 400 when permission is "*" but app_uid is missing', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRevokeUserApp(
makeReq({ permission: '*' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('AuthController.handleAppUidFromOrigin additional branches', () => {
it('reads origin from req.query as well as req.body', async () => {
const origin = `https://qparam-${uuidv4()}.example`;
const req = makeReq({});
(req as unknown as { query: Record }).query = {
origin,
};
const res = makeRes();
await controller.handleAppUidFromOrigin(req, res);
expect((res.body as { uid: string }).uid).toMatch(/^app-/);
});
});
describe('AuthController.handleCheckApp additional branches', () => {
it('rejects missing app_uid AND origin with 400', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleCheckApp(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('resolves origin → app_uid when app_uid is omitted', async () => {
const { actor } = await makeUserAndActor();
const origin = `https://co-${uuidv4()}.example`;
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckApp(makeReq({ origin }, { actor }), res),
);
const body = res.body as {
app_uid: string;
authenticated: boolean;
};
expect(body.app_uid).toMatch(/^app-/);
expect(typeof body.authenticated).toBe('boolean');
});
});
describe('AuthController 2FA additional branches', () => {
it('configure-2fa test: returns ok:false on a mismatched code', async () => {
// Setup so otp_secret is populated.
const { user, actor } = await makeUserAndActor();
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
makeRes(),
);
const refreshed = await server.stores.user.getById(user.id, {
force: true,
});
// Re-build the actor so it sees the freshly stored secret if cached.
void refreshed;
const res = makeRes();
await controller.handleConfigure2fa(
makeReq(
{ code: '000000' },
{ actor, params: { action: 'test' } },
),
res,
);
expect(res.body).toEqual({ ok: false });
});
it('configure-2fa enable: succeeds when email is confirmed and a secret exists', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 1 });
// Bootstrap a secret directly so we don't depend on the setup
// handler's side effects.
await server.clients.db.write(
'UPDATE `user` SET `otp_secret` = ? WHERE `uuid` = ?',
['TESTSECRETBASE32', user.uuid],
);
await server.stores.user.invalidateById(user.id);
const res = makeRes();
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'enable' } }),
res,
);
expect(res.body).toEqual({});
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_enabled).toBeTruthy();
});
it('disable-2fa: throws 404 when the user no longer exists', async () => {
const { user, actor } = await makeUserAndActor();
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
await expect(
controller.handleDisable2fa(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('AuthController.handleGetDevProfile additional branches', () => {
it('throws 404 when the actor has no matching user row', async () => {
const { user, actor } = await makeUserAndActor();
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
await expect(
controller.handleGetDevProfile(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('AuthController group endpoints: additional branches', () => {
it('group/remove-users: 400 on missing uid', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGroupRemoveUsers(
makeReq({ users: ['x'] }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('group/remove-users: 400 on non-array users', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGroupRemoveUsers(
makeReq({ uid: 'g-1' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('group/remove-users: 404 on unknown uid', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGroupRemoveUsers(
makeReq(
{ uid: `does-not-exist-${uuidv4()}`, users: [] },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('group/remove-users: 403 when caller does not own the group', async () => {
const { actor: a1 } = await makeUserAndActor();
const { actor: a2 } = await makeUserAndActor();
const createRes = makeRes();
await controller.handleGroupCreate(
makeReq({}, { actor: a1 }),
createRes,
);
const { uid } = createRes.body as { uid: string };
await expect(
controller.handleGroupRemoveUsers(
makeReq({ uid, users: [] }, { actor: a2 }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe('AuthController.handleGetGuiToken / handleSessionSyncCookie additional branches', () => {
it('get-gui-token: 404 when actor has a session but the user row is gone', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const sessionedActor = {
...actor,
session: { uid: sessionUid },
} as Actor;
// Pull the user row out from under the session.
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
await expect(
controller.handleGetGuiToken(
makeReq({}, { actor: sessionedActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('session/sync-cookie: 404 when actor has a session but the user row is gone', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const sessionedActor = {
...actor,
session: { uid: sessionUid },
} as Actor;
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
const res = makeRes();
await controller.handleSessionSyncCookie(
makeReq({}, { actor: sessionedActor }),
res,
);
expect(res.statusCode).toBe(404);
});
});
describe('AuthController.handleSendConfirmEmail additional branches', () => {
it('throws 404 when the actor user row no longer exists', async () => {
const { user, actor } = await makeUserAndActor();
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
await expect(
controller.handleSendConfirmEmail(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('AuthController.handleConfirmEmail additional branches', () => {
it('throws 404 when the actor user row no longer exists', async () => {
const { user, actor } = await makeUserAndActor();
await server.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
user.id,
]);
await server.stores.user.invalidateById(user.id);
await expect(
controller.handleConfirmEmail(
makeReq({ code: '000000' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
});
describe('AuthController.handleRevokeSession additional branches', () => {
it('successfully revokes the actor’s own session', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const res = makeRes();
await controller.handleRevokeSession(
makeReq({ uuid: sessionUid }, { actor }),
res,
);
const body = res.body as { sessions: unknown[] };
expect(Array.isArray(body.sessions)).toBe(true);
});
});