Files
puter/src/backend/controllers/auth/AuthController.test.ts
T
Daniel Salazar 3f2c45be26 cleanup: authcontroller with testable methods and tests (#3054)
* cleanup: authcontroller with testable methods and tests

* fix: types

Tested this on zenpacket, seems to all be working
2026-05-10 13:24:17 -07:00

3415 lines
122 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* 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<Record<string, unknown>> = [];
const installSharedListeners = () => {
eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => {
if (signupValidateOverride) {
signupValidateOverride(
data as Parameters<SignupValidateOverride>[0],
);
}
});
eventClient.on('puter.signup.success', (_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record<string, unknown>);
});
};
const withSignupValidateOverride = async <T>(
override: SignupValidateOverride,
fn: () => Promise<T>,
): Promise<T> => {
signupValidateOverride = override;
try {
return await fn();
} finally {
signupValidateOverride = null;
}
};
// ── Synthetic req/res helpers ───────────────────────────────────────
interface MockRes {
statusCode: number;
body: unknown;
headersSent: boolean;
cookies: Record<string, { value: string; opts?: Record<string, unknown> }>;
clearedCookies: string[];
sent: string | null;
ended: boolean;
status(code: number): MockRes;
json(body: unknown): MockRes;
cookie(
name: string,
value: string,
opts?: Record<string, unknown>,
): 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<string, unknown>) {
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<string, unknown> = {},
extra: Partial<{
actor: Actor;
token: string;
headers: Record<string, unknown>;
ip: string;
params: Record<string, string>;
}> = {},
) => ({
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 = <T>(actor: Actor | undefined, fn: () => Promise<T>): Promise<T> =>
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<string, unknown>) &&
(body as Record<string, unknown>).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:<permission>` 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<string, unknown>,
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:<app>:*`.
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<string, unknown>,
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<string, unknown> = {}) => {
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<Record<string, unknown>> = [];
const off = (() => {
const fn = (_k: unknown, data: unknown) => {
heard.push(data as Record<string, unknown>);
};
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<string, string> }).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<string, string> }).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<string, boolean> };
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<string, unknown>;
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<string, string> }).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<string, string> }).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<string, string> }).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<string, string> }).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);
});
});