Files
puter/src/backend/controllers/auth/AuthController.test.ts
T
Neal Shah 0462ddd6f5 add support for step-up sessions (#3395)
* add support for step-up sessions

* update step up session
2026-07-16 13:58:49 -04:00

6088 lines
220 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 jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } 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 { requireUserActorGate } from '../../core/http/middleware/gates.js';
import { PuterServer } from '../../server.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.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 heardUserDelete: Array<Record<string, unknown>> = [];
// Card verification is pure mechanism in core: a payments extension fills in the
// event fields via emitAndWait. These overrides let a test stand in for that
// extension, using the same shared-listener pattern as the signup validate
// override above (EventClient has no off()). null override => no extension.
type CardSetupOverride = (data: {
enabled: boolean | null;
client_secret: string | null;
publishable_key: string | null;
}) => void;
type CardConfirmOverride = (data: {
enabled: boolean | null;
verified: boolean;
reason: string | null;
}) => void;
let cardSetupOverride: CardSetupOverride | null = null;
let cardConfirmOverride: CardConfirmOverride | null = null;
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>);
});
eventClient.on('user.delete', (_k: unknown, data: unknown) => {
heardUserDelete.push(data as Record<string, unknown>);
});
eventClient.on(
'puter.card-verification.setup',
(_k: unknown, data: unknown) => {
if (cardSetupOverride) {
cardSetupOverride(data as Parameters<CardSetupOverride>[0]);
}
},
);
eventClient.on(
'puter.card-verification.confirm',
(_k: unknown, data: unknown) => {
if (cardConfirmOverride) {
cardConfirmOverride(data as Parameters<CardConfirmOverride>[0]);
}
},
);
};
const withSignupValidateOverride = async <T>(
override: SignupValidateOverride,
fn: () => Promise<T>,
): Promise<T> => {
signupValidateOverride = override;
try {
return await fn();
} finally {
signupValidateOverride = null;
}
};
const withCardSetupOverride = async <T>(
override: CardSetupOverride,
fn: () => Promise<T>,
): Promise<T> => {
cardSetupOverride = override;
try {
return await fn();
} finally {
cardSetupOverride = null;
}
};
const withCardConfirmOverride = async <T>(
override: CardConfirmOverride,
fn: () => Promise<T>,
): Promise<T> => {
cardConfirmOverride = override;
try {
return await fn();
} finally {
cardConfirmOverride = 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('forces phone verification on every signup when always_require_phone_verification is set', async () => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.always_require_phone_verification;
cfg.always_require_phone_verification = true;
try {
const username = `s_${uniq()}`;
const req = makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
});
const res = makeRes();
await controller.handleSignup(req, res);
// The login envelope flags the gate so the GUI shows the dialog …
const body = res.body as {
user: { requires_phone_verification?: number | boolean };
};
expect(body.user.requires_phone_verification).toBeTruthy();
// … and it's persisted so the gate survives re-login.
const persisted = await server.stores.user.getByUsername(username);
expect(persisted!.requires_phone_verification).toBe(true);
} finally {
cfg.always_require_phone_verification = prev;
}
});
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('rejects brand-new temp signups when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
await expect(
controller.handleSignup(makeReq({ is_temp: true }), makeRes()),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
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);
});
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
const targetEmail = `disabled_claim_${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);
const res = makeRes();
await controller.handleSignup(
makeReq({
username: `claim_${uniq()}`,
email: targetEmail,
password: 'correct-horse-battery',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const claimed = await server.stores.user.getById(placeholder.id, {
force: true,
});
expect(claimed!.username).not.toBe(placeholder.username);
} finally {
authConfig.disable_user_signup = prev;
}
});
it('does not reveal existing usernames or emails when registration is disabled', async () => {
const username = `taken_${uniq()}`;
const email = `${username}@test.local`;
await controller.handleSignup(
makeReq({ username, email, password: 'correct-horse-battery' }),
makeRes(),
);
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
// Taken username → the generic 403, not the duplicate error.
await expect(
controller.handleSignup(
makeReq({
username,
email: `fresh_${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
// Taken (non-claimable) email → same generic 403.
await expect(
controller.handleSignup(
makeReq({
username: `fresh_${uniq()}`,
email,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
});
// -- Signup device signal (fingerprint) --
describe('AuthController.handleSignup device signals', () => {
const uniq = () => Math.random().toString(36).slice(2, 10);
const captureValidateEvents = async (
fn: () => Promise<void>,
): Promise<Array<Record<string, unknown>>> => {
const seen: Array<Record<string, unknown>> = [];
await withSignupValidateOverride((event) => {
seen.push(event as unknown as Record<string, unknown>);
}, fn);
return seen;
};
const successEventsFor = (baseline: number, username: string) =>
heardSignupSuccess
.slice(baseline)
.filter(
(evt) => (evt as { username?: string }).username === username,
);
it('forwards fingerprint verbatim to validate and success events', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const fingerprint = 'Fp_abc.123-XYZ';
const seen = await captureValidateEvents(async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint,
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBe(fingerprint);
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].fingerprint).toBe(fingerprint);
expect(successes[0].is_temp).toBe(false);
});
it('accepts a boundary-length 128-char fingerprint', async () => {
const username = `fp_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 'f'.repeat(128),
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
it('defaults the fingerprint to null on the validate event when absent', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const seen = await captureValidateEvents(async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
);
// Signup completes exactly as before when the fields are absent.
expect(isCompleteLoginResponse(res.body)).toBe(true);
expect(res.cookies['puter_auth_token']).toBeDefined();
});
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].fingerprint).toBeNull();
expect(successes[0].is_temp).toBe(false);
});
it('treats an empty-string fingerprint as absent', async () => {
const username = `fp_${uniq()}`;
const seen = await captureValidateEvents(async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: '',
}),
res,
);
// An empty signal is "not collected", never a 400.
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
});
it('rejects a non-string fingerprint with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 12345,
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
expect(heardSignupSuccess.length).toBe(baseline);
// No user row was created either.
expect(await server.stores.user.getByUsername(username)).toBeFalsy();
});
it('rejects a fingerprint longer than 128 characters with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 'f'.repeat(129),
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(heardSignupSuccess.length).toBe(baseline);
});
it('temp-user signup reports is_temp true and carries the fingerprint on the success event', async () => {
const baseline = heardSignupSuccess.length;
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, fingerprint: 'temp-device-fp' }),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const username = (res.body as { user: { username: string } }).user
.username;
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].is_temp).toBe(true);
expect(successes[0].fingerprint).toBe('temp-device-fp');
});
it('pseudo-user claim reports is_temp false on the success event', async () => {
// Seed an unconfirmed placeholder row (email set, password null) —
// signing up with the same email claims it instead of inserting.
const placeholder = `ph_${uniq()}`;
const email = `${placeholder}@test.local`;
await server.stores.user.create({
username: placeholder,
uuid: uuidv4(),
password: null,
email,
});
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email,
password: 'correct-horse-battery',
fingerprint: 'claim-device-fp',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].is_temp).toBe(false);
expect(successes[0].fingerprint).toBe('claim-device-fp');
});
});
// ── 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 });
});
});
// ── Step-up (elevation) ─────────────────────────────────────────────
describe('AuthController.handleElevate', () => {
it('password account: correct password mints the elevation cookie', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleElevate(
makeReq({ password: 'correct-horse-battery' }, { actor }),
res,
);
expect(res.body).toMatchObject({ elevated: true });
expect(res.cookies.puter_elevated).toBeDefined();
expect(res.cookies.puter_elevated.opts).toMatchObject({ httpOnly: true });
// The minted cookie satisfies verifyStepUpSession for this same user.
const { verifyStepUpSession } = await import(
'../../core/http/middleware/stepUpSession.js'
);
const ok = verifyStepUpSession(
{
cookies: { puter_elevated: res.cookies.puter_elevated.value },
actor: { user: { uuid: actor.user.uuid } },
} as never,
{ tokenService: server.services.token },
);
expect(ok).toBe(true);
});
it('password account: wrong password → 401 password_mismatch', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleElevate(
makeReq({ password: 'nope' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 401,
legacyCode: 'password_mismatch',
});
});
it('2FA account: a live TOTP code elevates; a wrong code is rejected', async () => {
const { TOTP } = await import('otpauth');
const { createSecret } = await import(
'../../services/auth/OTPUtil.js'
);
const { user, actor } = await makeUserAndActor();
const { secret } = createSecret(user.username);
await server.stores.user.update(user.id, {
otp_enabled: 1,
otp_secret: secret,
});
// Reflect the enabled state on the actor the way the auth probe would.
const otpActor = {
user: { ...actor.user, otp_enabled: true },
} as never;
const totp = new TOTP({
issuer: 'puter.com',
label: user.username,
algorithm: 'SHA1',
digits: 6,
secret,
});
const res = makeRes();
await controller.handleElevate(
makeReq({ code: totp.generate() }, { actor: otpActor }),
res,
);
expect(res.body).toMatchObject({ elevated: true });
expect(res.cookies.puter_elevated).toBeDefined();
await expect(
controller.handleElevate(
makeReq({ code: '000000' }, { actor: otpActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('account with no password and 2FA off cannot elevate → 403', async () => {
const { user, actor } = await makeUserAndActor();
await server.stores.user.update(user.id, { password: null });
await expect(
controller.handleElevate(
makeReq({ password: 'anything' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'elevation_unavailable',
});
});
it('API clients (no cookie) get the token back to send as a header', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleElevate(
makeReq({ password: 'correct-horse-battery' }, { actor }),
res,
);
expect(typeof (res.body as { token?: string }).token).toBe('string');
});
it('browser sessions (cookie-authed) do NOT get the raw token in the body', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
// Mimic the browser: the resolved token IS the session cookie value.
// Cookie name must match `config.cookie_name` (puter_auth_token).
const req = {
...makeReq({ password: 'correct-horse-battery' }, { actor }),
token: 'session-cookie-value',
cookies: { puter_auth_token: 'session-cookie-value' },
};
await controller.handleElevate(req, res);
expect(res.body).toEqual({ elevated: true });
expect(res.cookies.puter_elevated).toBeDefined();
});
it('the elevation token is never honored as a main auth token', async () => {
const { user } = await makeUserAndActor();
const { signStepUpToken } = await import(
'../../core/http/middleware/stepUpSession.js'
);
const token = signStepUpToken(server.services.token, {
uuid: user.uuid,
});
const result = await server.services.auth.authenticate(token);
expect(result.actor).toBeUndefined();
});
});
// ── 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');
});
});
describe('AuthController account-lifecycle route gating', () => {
const routeOptions = (method: string, path: string) => {
const proto = Object.getPrototypeOf(controller) as {
__puterRoutes?: Array<{
method: string;
path: string;
options?: Record<string, unknown>;
}>;
};
const route = (proto.__puterRoutes ?? []).find(
(r) =>
r.method.toLowerCase() === method.toLowerCase() &&
r.path === path,
);
expect(route, `route ${method} ${path} not found`).toBeDefined();
return route!.options ?? {};
};
it('POST /logout requires a human user actor', () => {
const opts = routeOptions('post', '/logout');
expect(opts.requireUserActor).toBe(true);
expect(opts.antiCsrf).toBe(true);
});
it('GET /get-anticsrf-token requires a human user actor', () => {
const opts = routeOptions('get', '/get-anticsrf-token');
expect(opts.requireUserActor).toBe(true);
});
it('requireUserActorGate rejects app-under-user and access-token actors', () => {
const gate = requireUserActorGate();
const run = (actor: Partial<Actor>) =>
new Promise<unknown>((resolve) => {
gate(
{ actor } as never,
{} as never,
(err?: unknown) => resolve(err),
);
});
return (async () => {
const appActor = await run({
user: { uuid: 'u1' },
app: { uid: 'app-1' },
} as Partial<Actor>);
expect(appActor).toMatchObject({ statusCode: 403 });
const tokenActor = await run({
user: { uuid: 'u1' },
accessToken: { uid: 'tok-1' },
} as Partial<Actor>);
expect(tokenActor).toMatchObject({ statusCode: 403 });
const human = await run({ user: { uuid: 'u1' } } as Partial<Actor>);
expect(human).toBeUndefined();
})();
});
});
// ── 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);
});
// -- Full-API-access + labels --
it('mints a full-access token and stores a trimmed label on the session row', async () => {
const res = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{ permissions: [FULL_API_ACCESS], label: ' My CLI ' },
{ actor },
),
res,
);
const decoded = server.services.token.verify(
'auth',
(res.body as { token: string }).token,
) as {
type: string;
token_uid: string;
session_uid: string;
full_access?: boolean;
};
expect(decoded.type).toBe('access-token');
// Full access is a signed claim, not a stored permission row.
expect(decoded.full_access).toBe(true);
const permRows = (await server.clients.db.read(
'SELECT `permission` FROM `access_token_permissions` WHERE `token_uid` = ?',
[decoded.token_uid],
)) as Array<{ permission: string }>;
expect(permRows).toHaveLength(0);
const sessRows = (await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[decoded.session_uid],
)) as Array<{ label: string }>;
expect(sessRows[0]?.label).toBe('My CLI');
});
it('caps an over-long label at 64 characters', async () => {
const res = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{ permissions: [FULL_API_ACCESS], label: 'x'.repeat(120) },
{ actor },
),
res,
);
const decoded = server.services.token.verify(
'auth',
(res.body as { token: string }).token,
) as { session_uid: string };
const sessRows = (await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[decoded.session_uid],
)) as Array<{ label: string }>;
expect(sessRows[0]?.label.length).toBe(64);
});
it('rejects a non-string label with 400', async () => {
await expect(
controller.handleCreateAccessToken(
makeReq(
{
permissions: [FULL_API_ACCESS],
label: 123 as unknown as string,
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
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);
});
});
// The per-account / per-number SMS send caps used to live here as a hardcoded
// `MAX_PHONE_VERIFY_SENDS` in the backend. They now live entirely in the abuse
// extension (no abuse thresholds in the OSS repo): the backend only asks via
// `puter.phone-verification.check` and reports sends via
// `puter.phone-verification.sent`. The cap behavior is covered by the
// extension's phoneVerification / phoneSendLog tests; the backend side (it
// forwards a veto, and emits `sent` only on success) is covered below.
describe('AuthController.handleSendConfirmPhone validation', () => {
// Stub the Prelude client (a real external boundary) so we exercise the
// controller's validation branches, not the network. Override per case.
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
isCountrySupported: () => true,
defaultCountry: 'US',
createVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withPrelude = async (
prelude: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { prelude: unknown } };
const real = ctrl.clients.prelude;
ctrl.clients.prelude = prelude;
try {
await fn();
} finally {
ctrl.clients.prelude = real;
}
};
it('throws 400 for an unparseable phone number', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(stubPrelude(), async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: 'not a phone' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
it('throws 400 (and sends nothing) when the country is over the cost cap', async () => {
const { actor } = await makeUserAndActor();
const createVerification = vi.fn(async () => ({ status: 'success' }));
await withPrelude(
stubPrelude({ isCountrySupported: () => false, createVerification }),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(createVerification).not.toHaveBeenCalled();
},
);
});
it('throws 503 when Prelude is not configured', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({ isConfigured: () => false }),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 503 });
},
);
});
it('surfaces a Prelude block as 429', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({ status: 'blocked' })),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 429 });
},
);
});
it('attaches a support error_id to a Prelude block', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({ status: 'blocked' })),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { error_id: expect.any(String) },
});
},
);
});
it('attaches a support error_id when the Prelude request throws', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('network down');
}),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 502,
fields: { error_id: expect.any(String) },
});
},
);
});
it('persists the send failure to KV under the error_id with a 7-day expiry', async () => {
const { user, actor } = await makeUserAndActor();
const kvSet = vi.spyOn(server.stores.kv, 'set');
try {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('network down');
}),
}),
async () => {
let thrown: HttpError | undefined;
try {
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
);
} catch (e) {
thrown = e as HttpError;
}
const errorId = thrown!.fields!.error_id as string;
const { res: record } = await server.stores.kv.get({
key: `sms-send-error:${errorId}`,
});
expect(record).toMatchObject({
reason: 'prelude_request_failed',
status: 502,
user_id: user.id,
user_uid: user.uuid,
detail: 'network down',
t: expect.any(Number),
});
const errorSet = kvSet.mock.calls.find(([arg]) =>
(arg as { key?: string }).key?.startsWith(
'sms-send-error:',
),
);
const { expireAt } = errorSet![0] as {
expireAt: number;
};
const nowSec = Math.floor(Date.now() / 1000);
expect(expireAt).toBeGreaterThan(
nowSec + 7 * 24 * 60 * 60 - 60,
);
expect(expireAt).toBeLessThanOrEqual(
nowSec + 7 * 24 * 60 * 60,
);
},
);
} finally {
kvSet.mockRestore();
}
});
it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => {
const { actor } = await makeUserAndActor();
const createVerification = vi.fn(async () => ({ status: 'success' }));
await withPrelude(stubPrelude({ createVerification }), async () => {
const req = makeReq(
{ phone: '+14155550123' },
{
actor,
ip: '203.0.113.7',
headers: { 'user-agent': 'Mozilla/5.0' },
},
);
// Stamped by the global fingerprint middleware in production.
(req as { deviceFingerprint?: string }).deviceFingerprint =
'thumb_abc123';
await controller.handleSendConfirmPhone(req, makeRes());
});
expect(createVerification).toHaveBeenCalledWith('+14155550123', {
ip: '203.0.113.7',
device_id: 'thumb_abc123',
user_agent: 'Mozilla/5.0',
});
});
it('omits absent device fingerprint and user-agent from the Prelude signals', async () => {
const { actor } = await makeUserAndActor();
const createVerification = vi.fn(async () => ({ status: 'success' }));
await withPrelude(stubPrelude({ createVerification }), async () => {
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor, ip: '203.0.113.7' }),
makeRes(),
);
});
expect(createVerification).toHaveBeenCalledWith('+14155550123', {
ip: '203.0.113.7',
device_id: undefined,
user_agent: undefined,
});
});
it('returns the delivery channel Prelude picked so the client can point at the right app', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'success',
channels: ['whatsapp', 'sms'],
})),
}),
async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toMatchObject({ channel: 'whatsapp' });
},
);
});
it('omits `channel` when Prelude reports no delivery sequence', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).not.toHaveProperty('channel');
});
});
});
describe('AuthController phone verification — staging & reuse', () => {
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
isCountrySupported: () => true,
defaultCountry: 'US',
createVerification: vi.fn(async () => ({ status: 'success' })),
checkVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withClients = async (
over: { prelude?: unknown; event?: unknown },
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as {
clients: { prelude: unknown; event: unknown };
};
const realPrelude = ctrl.clients.prelude;
const realEvent = ctrl.clients.event;
if ('prelude' in over) ctrl.clients.prelude = over.prelude;
if ('event' in over) ctrl.clients.event = over.event;
try {
await fn();
} finally {
ctrl.clients.prelude = realPrelude;
ctrl.clients.event = realEvent;
}
};
it('stages the number in KV and does NOT write it to the user row before verification', async () => {
const { user, actor } = await makeUserAndActor();
await withClients({ prelude: stubPrelude() }, async () => {
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
);
});
const after = await server.stores.user.getById(user.id, {
force: true,
});
// The unverified number is not persisted to the indexed column …
expect(after!.phone ?? null).toBeNull();
// … it's staged in KV instead, for /confirm-phone to read back.
const { res: staged } = await server.stores.kv.get({
key: `phone-verify-pending:${user.id}`,
});
expect(staged).toBe('+14155550123');
});
it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => {
const { user, actor } = await makeUserAndActor();
const emitAndWait = vi.fn(
async (
name: string,
ev: { allowed: boolean; reason: string | null },
) => {
if (name === 'puter.phone-verification.check') {
ev.allowed = false;
ev.reason = 'phone_already_used';
}
},
);
const createVerification = vi.fn(async () => ({ status: 'success' }));
await withClients(
{
prelude: stubPrelude({ createVerification }),
event: { emitAndWait, emit: vi.fn() },
},
async () => {
// 429 (not 403), and the extension's reason is forwarded
// verbatim for the client to message on — the backend never
// interprets it.
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { reason: 'phone_already_used' },
});
},
);
// Vetoed before the send — no SMS dispatched, nothing staged.
expect(createVerification).not.toHaveBeenCalled();
const { res: staged } = await server.stores.kv.get({
key: `phone-verify-pending:${user.id}`,
});
expect(staged ?? null).toBeNull();
});
it('emits puter.phone-verification.sent after a successful send', async () => {
const { actor } = await makeUserAndActor();
const emit = vi.fn();
const emitAndWait = vi.fn(async () => {}); // no veto
await withClients(
{ prelude: stubPrelude(), event: { emit, emitAndWait } },
async () => {
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
);
},
);
expect(emit).toHaveBeenCalledWith(
'puter.phone-verification.sent',
expect.objectContaining({ phone: '+14155550123' }),
expect.anything(),
);
});
it('does NOT emit the sent signal when the send fails upstream', async () => {
const { actor } = await makeUserAndActor();
const emit = vi.fn();
const emitAndWait = vi.fn(async () => {});
await withClients(
{
prelude: stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('prelude down');
}),
}),
event: { emit, emitAndWait },
},
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 502 });
},
);
const sentCalls = emit.mock.calls.filter(
(c) => c[0] === 'puter.phone-verification.sent',
);
expect(sentCalls).toHaveLength(0);
});
it('confirms against the staged number and persists it to the row only on success', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No phone on the row; the number lives only in the KV staging slot.
await server.stores.kv.set({
key: `phone-verify-pending:${user.id}`,
value: '+14155550123',
});
const checkVerification = vi.fn(async () => ({ status: 'success' }));
await withClients(
{ prelude: stubPrelude({ checkVerification }) },
async () => {
const res = makeRes();
await controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
res,
);
expect(res.body).toMatchObject({ phone_verified: true });
},
);
expect(checkVerification).toHaveBeenCalledWith('+14155550123', '123456');
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(false);
// Persisted to the row only now, on success.
expect(after!.phone).toBe('+14155550123');
});
});
describe('AuthController.handleConfirmPhone', () => {
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
checkVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withPrelude = async (
prelude: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { prelude: unknown } };
const real = ctrl.clients.prelude;
ctrl.clients.prelude = prelude;
try {
await fn();
} finally {
ctrl.clients.prelude = real;
}
};
it('throws 400 when code is missing', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await expect(
controller.handleConfirmPhone(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 400 });
});
it('short-circuits to verified when the gate is not set (no Prelude call)', async () => {
const { actor } = await makeUserAndActor();
const checkVerification = vi.fn();
await withPrelude(stubPrelude({ checkVerification }), async () => {
const res = makeRes();
await controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
res,
);
expect(res.body).toMatchObject({ phone_verified: true });
expect(checkVerification).not.toHaveBeenCalled();
});
});
it('throws 400 when the gate is set but no phone is on file', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await expect(
controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 503 when the gate is set but Prelude is not configured', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await withPrelude(
stubPrelude({ isConfigured: () => false }),
async () => {
await expect(
controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 503 });
},
);
});
it('returns phone_verified:false on a wrong code without clearing the gate', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await withPrelude(
stubPrelude({
checkVerification: vi.fn(async () => ({ status: 'failure' })),
}),
async () => {
const res = makeRes();
await controller.handleConfirmPhone(
makeReq({ code: '000000' }, { actor }),
res,
);
expect(res.body).toMatchObject({ phone_verified: false });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(true);
},
);
});
it('clears the gate on a correct code and echoes the socket id', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await withPrelude(
stubPrelude({
checkVerification: vi.fn(async () => ({ status: 'success' })),
}),
async () => {
const res = makeRes();
await controller.handleConfirmPhone(
makeReq(
{ code: '123456', original_client_socket_id: 'sock_1' },
{ actor },
),
res,
);
expect(res.body).toMatchObject({
phone_verified: true,
original_client_socket_id: 'sock_1',
});
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(false);
},
);
});
it('awaits the user.phone-verified listeners (emitAndWait, not fire-and-forget)', async () => {
// The carrier-based card-verification waiver (abuse extension) listens
// on user.phone-verified and must clear the card gate BEFORE confirm
// responds — so the event has to be awaited, not fire-and-forget.
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
const emit = vi.fn();
const emitAndWait = vi.fn(async () => {});
const ctrl = controller as { clients: { event: unknown } };
const realEvent = ctrl.clients.event;
ctrl.clients.event = { emit, emitAndWait };
try {
await withPrelude(
stubPrelude({
checkVerification: vi.fn(async () => ({
status: 'success',
})),
}),
async () => {
await controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
makeRes(),
);
},
);
} finally {
ctrl.clients.event = realEvent;
}
expect(emitAndWait).toHaveBeenCalledWith(
'user.phone-verified',
expect.objectContaining({ phone: '+14155550123' }),
expect.anything(),
);
const fireAndForget = emit.mock.calls.filter(
(c) => c[0] === 'user.phone-verified',
);
expect(fireAndForget).toHaveLength(0);
});
it('throws 502 when the upstream check fails', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await withPrelude(
stubPrelude({
checkVerification: vi.fn(async () => {
throw new Error('prelude down');
}),
}),
async () => {
await expect(
controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 502 });
},
);
});
});
describe('AuthController.handleCardVerificationSetup', () => {
it('short-circuits to verified when the gate is not set', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
);
expect(res.body).toMatchObject({ card_verified: true });
});
it('throws 403 when the account is suspended', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
suspended: 1,
});
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('throws 409 when phone verification must be completed first', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
it('throws 503 when no payments extension is listening', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
});
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('clears the gate when the extension reports the feature disabled', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
});
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = false;
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toMatchObject({ card_verified: true, disabled: true });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
});
it('returns the provider credentials on success', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
});
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
});
});
describe('AuthController.handleCardVerificationConfirm', () => {
it('throws 400 for a missing or invalid setup_intent_id', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
});
for (const bad of [undefined, '', 123, 'x'.repeat(256)]) {
await expect(
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: bad }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
}
});
it('short-circuits to verified when the gate is not set', async () => {
const { actor } = await makeUserAndActor();
const res = makeRes();
await controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
);
expect(res.body).toMatchObject({ card_verified: true });
});
it('throws 409 when phone verification must be completed first', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await expect(
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
it('throws 503 when no payments extension is listening', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
});
await expect(
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('clears the gate when the extension reports the feature disabled', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
});
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = false;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({ card_verified: true, disabled: true });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
});
it('returns card_verified:false with a reason when not verified', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
});
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = false;
data.reason = 'prepaid_not_allowed';
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({
card_verified: false,
reason: 'prepaid_not_allowed',
});
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(true);
});
it('clears the gate when the extension verifies the card', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
});
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq(
{
setup_intent_id: 'seti_1',
original_client_socket_id: 'sock_1',
},
{ actor },
),
res,
),
);
expect(res.body).toMatchObject({ card_verified: true });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
});
});
describe('AuthController SMS → card fallback', () => {
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
isCountrySupported: () => true,
defaultCountry: 'US',
createVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withPrelude = async (
prelude: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { prelude: unknown } };
const real = ctrl.clients.prelude;
ctrl.clients.prelude = prelude;
try {
await fn();
} finally {
ctrl.clients.prelude = real;
}
};
const withFallbackConfig = async (
value: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.phone_verification_card_fallback;
cfg.phone_verification_card_fallback = value;
try {
await fn();
} finally {
cfg.phone_verification_card_fallback = prev;
}
};
// Drive the attempt counter directly so the threshold is deterministic
// (the handler keys it the same way: `phone-verify-attempts:<id>`).
const seedAttempts = (userId: number, attempts: number) =>
server.stores.kv.incr({
key: `phone-verify-attempts:${userId}`,
pathAndAmountMap: { attempts },
});
// Stamp the eligibility flag the card endpoints check, the same way a
// threshold-crossing send does (`card-fallback-open:<id>`).
const openFallback = (userId: number) =>
server.stores.kv.set({
key: `card-fallback-open:${userId}`,
value: true,
});
it('offers the fallback on send once the attempt threshold is reached', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No after_attempts → exercises the default threshold of 2.
await withFallbackConfig({ enabled: true }, async () => {
await withPrelude(stubPrelude(), async () => {
const first = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
first,
);
// First attempt is below the threshold — no offer yet.
expect(first.body).toEqual({});
const second = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
second,
);
expect(second.body).toEqual({
card_fallback_available: true,
});
});
});
});
it('never offers the fallback on send when disabled', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: false, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
},
);
});
it('flags the fallback on a Prelude block once eligible', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'blocked',
})),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { card_fallback_available: true },
});
},
);
},
);
});
it('lets card setup proceed past the phone gate once eligible', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('still 409s card setup when no send has opened the fallback', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
// Counter above the threshold but no flag: eligibility is the flag a
// threshold-crossing send stamps, never the raw counter.
await seedAttempts(user.id, 5);
await withFallbackConfig(
{ enabled: true, after_attempts: 3 },
async () => {
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
},
);
});
it('send crossing the threshold opens card setup end-to-end', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const sendRes = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
sendRes,
);
expect(sendRes.body).toEqual({
card_fallback_available: true,
});
});
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('clamps after_attempts to the send route rate limit', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// 9 prior attempts + this send = 10, the route limit. A threshold of
// 50 could never be crossed, so it clamps down and the offer opens.
await seedAttempts(user.id, 9);
await withFallbackConfig(
{ enabled: true, after_attempts: 50 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({
card_fallback_available: true,
});
});
},
);
});
it('clears BOTH gates when the fallback card verifies', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({
card_verified: true,
phone_verified: true,
});
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
expect(after!.requires_phone_verification).toBe(false);
});
it('clears the phone gate via card even when card was not required', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({ phone_verified: true });
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(false);
});
});
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 });
});
it('set-pass-using-token: revokes all of the users interactive sessions', async () => {
// A password reset is the "someone may have my account" flow, so
// existing sessions must not survive it.
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 s1 = await server.services.auth.createSessionToken(user, {});
const s2 = await server.services.auth.createSessionToken(user, {});
const uuid1 = (s1.session as { uuid: string }).uuid;
const uuid2 = (s2.session as { uuid: string }).uuid;
// Both are live before the reset.
expect(await server.stores.session.getByUuid(uuid1)).not.toBeNull();
expect(await server.stores.session.getByUuid(uuid2)).not.toBeNull();
await controller.handleSetPassUsingToken(
makeReq({ token: jwt, password: 'a-brand-new-password' }),
makeRes(),
);
// Every interactive session is revoked (getByUuid gates on revoked_at).
expect(await server.stores.session.getByUuid(uuid1)).toBeNull();
expect(await server.stores.session.getByUuid(uuid2)).toBeNull();
});
});
// ── 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-password: revokes the users other web sessions but keeps the current one', async () => {
const { user, actor } = await makeUserAndActor();
// Two web sessions for this user; one is the session performing the
// change (the "current" one, identified via actor.session.uid).
const current = await server.services.auth.createSessionToken(user, {});
const other = await server.services.auth.createSessionToken(user, {});
const currentUuid = (current.session as { uuid: string }).uuid;
const otherUuid = (other.session as { uuid: string }).uuid;
const req = makeReq(
{ new_pass: 'a-fresh-password-123' },
{
actor: {
...actor,
session: { uid: currentUuid },
} as typeof actor,
},
);
(req as unknown as { userProtected: unknown }).userProtected = { user };
await controller.handleChangePassword(req, makeRes());
// The other session is revoked; the one that made the change survives.
expect(await server.stores.session.getByUuid(otherUuid)).toBeNull();
expect(
await server.stores.session.getByUuid(currentUuid),
).not.toBeNull();
});
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 elses 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 });
});
it('rename-session: 400 when uuid param is missing', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRenameSession(
makeReq({ label: 'x' }, { actor, params: {} }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rename-session: 400 when label is the wrong type', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRenameSession(
makeReq(
{ label: 123 as unknown as string },
{ actor, params: { uuid: 'whatever' } },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rename-session: 400 when label field is missing entirely', async () => {
// Guards against accidental "PATCH with empty body silently clears
// the label". Type guard rejects `undefined` before reaching the
// service layer.
const { actor } = await makeUserAndActor();
await expect(
controller.handleRenameSession(
makeReq({}, { actor, params: { uuid: 'whatever' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rename-session: 404 when the uuid belongs to another user', async () => {
const { user: u1 } = await makeUserAndActor();
const { actor: a2 } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
u1,
{},
);
const uuid = (sessionRes.session as { uuid: string }).uuid;
await expect(
controller.handleRenameSession(
makeReq(
{ label: 'pwned' },
{ actor: a2, params: { uuid } },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('rename-session: success updates the row label', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const uuid = (sessionRes.session as { uuid: string }).uuid;
const res = makeRes();
await controller.handleRenameSession(
makeReq({ label: 'My Phone' }, { actor, params: { uuid } }),
res,
);
expect(res.body).toEqual({});
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[uuid],
);
expect((rows[0] as { label: string }).label).toBe('My Phone');
});
it('rename-session: accepts null to clear the label', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const uuid = (sessionRes.session as { uuid: string }).uuid;
// Seed a non-null label so the clear-to-null transition is observable.
await server.clients.db.write(
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ?',
['something', uuid],
);
await controller.handleRenameSession(
makeReq({ label: null }, { actor, params: { uuid } }),
makeRes(),
);
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[uuid],
);
expect((rows[0] as { label: string | null }).label).toBeNull();
});
});
// ── 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 doesnt 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();
});
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
// the sqlite ones the test harness runs — add it so the delete path
// captures it (it's how the marketplace extension cancels the sub).
try {
await server.clients.db.write(
'ALTER TABLE user ADD COLUMN stripe_customer_id TEXT',
[],
);
} catch {
/* already exists */
}
const { user, actor } = await makeUserAndActor();
await server.clients.db.write(
'UPDATE user SET stripe_customer_id = ? WHERE id = ?',
['cus_delete_test', user.id],
);
heardUserDelete.length = 0;
await controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes());
const evt = heardUserDelete.find((e) => e.user_id === user.id);
expect(evt).toMatchObject({
user_id: user.id,
user_uuid: user.uuid,
stripe_customer_id: 'cus_delete_test',
});
});
});
// ── 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('claim clears stale phone/card gates on the placeholder when the decision no longer requires them', async () => {
// Placeholder seeded with both gates already set. A benign claim
// (no validate override → no requirements) must reset them rather
// than silently inheriting the stale requirement.
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,
requires_phone_verification: 1,
requires_card_verification: 1,
} as never);
const res = makeRes();
await controller.handleSignup(
makeReq({
username: `claim_${uniq()}`,
email: targetEmail,
password: 'correct-horse-battery',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const claimed = await server.stores.user.getById(placeholder.id, {
force: true,
});
expect(claimed!.requires_phone_verification).toBe(false);
expect(claimed!.requires_card_verification).toBe(false);
});
it('claim carries the phone/card gates when the decision requires them', async () => {
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);
await withSignupValidateOverride(
(event) => {
event.requires_phone_verification = true;
event.requires_card_verification = true;
},
async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username: `claim_${uniq()}`,
email: targetEmail,
password: 'correct-horse-battery',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
},
);
const claimed = await server.stores.user.getById(placeholder.id, {
force: true,
});
expect(claimed!.requires_phone_verification).toBe(true);
expect(claimed!.requires_card_verification).toBe(true);
});
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 actors 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);
});
it('refuses to revoke the callers OWN current session row (400)', async () => {
// Invariant: a self-revoke leaves the client in an ambiguous
// identity state because the response can't write fresh auth
// state. /logout is the only path that should end the session
// you're currently authenticated under.
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const actorWithSession = {
...actor,
session: { uid: sessionUid },
} as Actor;
await expect(
controller.handleRevokeSession(
makeReq({ uuid: sessionUid }, { actor: actorWithSession }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
});
it('still allows revoking a DIFFERENT session belonging to the same user', async () => {
// Sanity check that the self-revoke guard only blocks the
// caller's own uuid — sibling rows must still be revokable
// (that's the whole point of manage-sessions).
const { user, actor } = await makeUserAndActor();
const callerSession = await server.services.auth.createSessionToken(
user,
{},
);
const targetSession = await server.services.auth.createSessionToken(
user,
{},
);
const actorWithSession = {
...actor,
session: {
uid: (callerSession.session as { uuid: string }).uuid,
},
} as Actor;
const res = makeRes();
await controller.handleRevokeSession(
makeReq(
{ uuid: (targetSession.session as { uuid: string }).uuid },
{ actor: actorWithSession },
),
res,
);
expect((res.body as { sessions: unknown[] }).sessions).toBeDefined();
});
});
// ── handleMigrateToken ──────────────────────────────────────────────
describe('AuthController.handleMigrateToken', () => {
const TEST_ORIGIN = 'https://migrate.test.local';
// PuterServer keeps config in a private field (#config), so we go
// through the controller — IController stores it as `protected
// config` which TS marks but JS doesn't enforce, and the controller
// is the actual consumer of #isMigrateTokenOriginAllowed anyway.
const controllerConfig = () =>
(controller as { config: Record<string, unknown> }).config;
// Mints a v1-shaped JWT signed under the test server's legacy
// secret. The body matches what migrateLegacyToken expects per
// `decoded.type`.
const mintV1Token = (payload: Record<string, unknown>): string => {
const legacy = controllerConfig().jwt_secret as string | undefined;
if (!legacy) throw new Error('test config missing jwt_secret');
return jwt.sign(payload, legacy);
};
beforeAll(() => {
// Make the origin allow-check pass for these tests. We mutate
// the live config because setupTestServer is shared across the
// file; the original value is undefined (default config has no
// `origin`) so we don't need to restore.
controllerConfig().origin = TEST_ORIGIN;
});
it('rejects when the Origin header is missing', async () => {
await expect(
controller.handleMigrateToken(makeReq({}), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });
});
it('allows the exchange from an unlisted origin (apps live on arbitrary domains)', async () => {
// puter.js apps run on any third-party domain; the v1 bearer
// token is the credential, so the exchange itself is not
// origin-gated — only cookie issuance is (next test).
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: 'https://some-app.example',
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
expect((res.body as { token: string }).token).toBeTruthy();
});
it('does NOT set the puter_token_v2 cookie for an app token from an untrusted origin', async () => {
// Cookie planting on the GUI origin is what the allowlist
// prevents: an attacker page may exchange a token it already
// holds, but must not be able to set a session cookie.
const { user } = await makeUserAndActor();
const appUid = `app-${uuidv4()}`;
const v1 = mintV1Token({
type: 'app-under-user',
user_uid: user.uuid,
app_uid: appUid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: 'https://some-app.example',
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('app');
expect((res.body as { token: string }).token).toBeTruthy();
expect(res.cookies.puter_token_v2).toBeUndefined();
});
it('normalizes trailing slash on the request Origin (B4)', async () => {
// The Origin header per spec doesn't carry a trailing slash, but
// a misconfigured proxy or a deployment with config.origin
// ending in `/` would otherwise force every call to reject.
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: `${TEST_ORIGIN}/`, // trailing slash
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
});
it('normalizes case on the request Origin (B4)', async () => {
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN.toUpperCase(),
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
});
it('returns 409 reauth_required for v1 web/session tokens', async () => {
// Web tokens never migrate silently — they always go through
// the interactive reauth flow. The body code is what puter.js /
// GUI key on; the 409 status is what tells SDK code "this
// isn't a generic auth failure, route through reauth".
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'session',
user_uid: user.uuid,
uuid: uuidv4(),
});
await expect(
controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${v1}`,
},
},
),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 409,
code: 'reauth_required',
});
});
it('does NOT set the puter_token_v2 cookie when migrating an access token (B3)', async () => {
// Access tokens are programmatic — they ride in Authorization
// headers, not browser cookies. Setting a cookie here would
// confuse cookie-only middleware downstream.
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect(res.cookies.puter_token_v2).toBeUndefined();
expect((res.body as { kind: string }).kind).toBe('access_token');
expect((res.body as { token: string }).token).toBeTruthy();
});
it('sets the puter_token_v2 cookie when migrating an app-under-user token (B3)', async () => {
// App tokens DO get a cookie companion — the app runs inside an
// iframe in the GUI, and the GUI's cookie-only middleware
// authenticates subsequent calls from the iframe via the cookie
// rather than the client having to plumb Authorization through
// every request.
const { user } = await makeUserAndActor();
const appUid = `app-${uuidv4()}`;
const v1 = mintV1Token({
type: 'app-under-user',
user_uid: user.uuid,
app_uid: appUid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: TEST_ORIGIN,
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('app');
const cookie = res.cookies.puter_token_v2;
expect(cookie).toBeDefined();
expect(cookie.value).toBe((res.body as { token: string }).token);
expect(cookie.opts?.httpOnly).toBe(true);
});
});
// -- auth_id preservation on forced re-login --
describe('AuthController auth_id preservation on reauth', () => {
const password = 'correct-horse-battery';
const mintReauth = (uuid: string): string =>
server.services.auth.signReauthToken(uuid);
it('handleLogin with matching reauth_token completes login as the same user', async () => {
const u = `aid_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.1`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
const res = makeRes();
await controller.handleLogin(
makeReq(
{
username: u,
password,
reauth_token: mintReauth(seeded!.uuid),
},
{ ip },
),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
expect((res.body as { user: { uuid: string } }).user.uuid).toBe(
seeded!.uuid,
);
});
it('handleLogin with mismatched reauth_token is rejected 409', async () => {
const a = `aida_${Math.random().toString(36).slice(2, 10)}`;
const b = `aidb_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.2`;
await controller.handleSignup(
makeReq(
{ username: a, email: `${a}@test.local`, password },
{ ip },
),
makeRes(),
);
await controller.handleSignup(
makeReq(
{ username: b, email: `${b}@test.local`, password },
{ ip },
),
makeRes(),
);
const userB = await server.stores.user.getByUsername(b);
await expect(
controller.handleLogin(
makeReq(
{
username: a,
password,
reauth_token: mintReauth(userB!.uuid),
},
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 409,
fields: { code: 'auth_id_mismatch' },
});
});
it('handleLogin with reauth_token for an unknown user returns 404', async () => {
const u = `aidu_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.3`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
makeRes(),
);
await expect(
controller.handleLogin(
makeReq(
{
username: u,
password,
reauth_token: mintReauth(uuidv4()),
},
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('handleLogin with a non-string reauth_token returns 400', async () => {
const u = `aidb_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.4`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
makeRes(),
);
await expect(
controller.handleLogin(
makeReq({ username: u, password, reauth_token: 42 }, { ip }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('handleLogin with a forged/garbage reauth_token returns 401', async () => {
const u = `aidf_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.9`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
makeRes(),
);
await expect(
controller.handleLogin(
makeReq(
{ username: u, password, reauth_token: 'not-a-jwt' },
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('handleLogin OTP branch echoes auth_id into the OTP JWT', async () => {
const u = `aidotp_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.5`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
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,
reauth_token: mintReauth(seeded!.uuid),
},
{ ip },
),
res,
);
expect(res.statusCode).toBe(202);
const body = res.body as { otp_jwt_token: string };
const decoded = server.services.token.verify(
'otp',
body.otp_jwt_token,
) as {
user_uid: string;
auth_id?: string;
};
expect(decoded.auth_id).toBe(seeded!.uuid);
});
it('handleSignup is_temp + matching reauth_token returns the SAME temp user', async () => {
const tempRes1 = makeRes();
const ip = `127.0.${Math.floor(Math.random() * 200)}.6`;
await controller.handleSignup(
makeReq({ is_temp: true }, { ip }),
tempRes1,
);
const body1 = tempRes1.body as { user: { uuid: string } };
const tempUuid = body1.user.uuid;
const tempUser1 = await server.stores.user.getByUuid(tempUuid);
expect(tempUser1).toBeTruthy();
const markerId = tempUser1!.id;
const tempRes2 = makeRes();
await controller.handleSignup(
makeReq(
{ is_temp: true, reauth_token: mintReauth(tempUuid) },
{ ip },
),
tempRes2,
);
expect(isCompleteLoginResponse(tempRes2.body)).toBe(true);
const body2 = tempRes2.body as {
user: { uuid: string; is_temp: boolean };
};
expect(body2.user.uuid).toBe(tempUuid);
expect(body2.user.is_temp).toBe(true);
const tempUser2 = await server.stores.user.getByUuid(tempUuid);
expect(tempUser2!.id).toBe(markerId);
});
it('handleSignup is_temp + reauth_token pointing at a permanent user is rejected', async () => {
const u = `aidperm_${Math.random().toString(36).slice(2, 10)}`;
const ip = `127.0.${Math.floor(Math.random() * 200)}.7`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
await expect(
controller.handleSignup(
makeReq(
{
is_temp: true,
reauth_token: mintReauth(seeded!.uuid),
},
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('handleSignup is_temp + reauth_token for an unknown user returns 404', async () => {
const ip = `127.0.${Math.floor(Math.random() * 200)}.8`;
await expect(
controller.handleSignup(
makeReq(
{
is_temp: true,
reauth_token: mintReauth(uuidv4()),
},
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('handleSignup is_temp rejects a forged reauth_token (401)', async () => {
const ip = `127.0.${Math.floor(Math.random() * 200)}.10`;
await expect(
controller.handleSignup(
makeReq(
{ is_temp: true, reauth_token: 'not-a-jwt' },
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
});
it('rate-limits reauth_token login attempts per IP', async () => {
const ip = `10.99.${Math.floor(Math.random() * 200)}.${Math.floor(Math.random() * 200)}`;
const u = `aidrl_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq(
{ username: u, email: `${u}@test.local`, password },
{ ip },
),
makeRes(),
);
const seeded = await server.stores.user.getByUsername(u);
for (let i = 0; i < 5; i++) {
const res = makeRes();
await controller.handleLogin(
makeReq(
{
username: u,
password,
reauth_token: mintReauth(seeded!.uuid),
},
{ ip },
),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
}
await expect(
controller.handleLogin(
makeReq(
{
username: u,
password,
reauth_token: mintReauth(seeded!.uuid),
},
{ ip },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 429 });
});
});