mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-06 13:25:39 +00:00
6088 lines
220 KiB
TypeScript
6088 lines
220 KiB
TypeScript
/**
|
||
* 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 user’s 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 user’s 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 else’s session', async () => {
|
||
const { user: u1 } = await makeUserAndActor();
|
||
const { actor: a2 } = await makeUserAndActor();
|
||
// Create a real session for u1 so the lookup succeeds, then attempt
|
||
// to revoke it as a2 — must 403.
|
||
const sessionRes = await server.services.auth.createSessionToken(
|
||
u1,
|
||
{},
|
||
);
|
||
await expect(
|
||
controller.handleRevokeSession(
|
||
makeReq(
|
||
{ uuid: (sessionRes.session as { uuid: string }).uuid },
|
||
{ actor: a2 },
|
||
),
|
||
makeRes(),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 403 });
|
||
});
|
||
|
||
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 doesn’t own the group', async () => {
|
||
const { actor: a1 } = await makeUserAndActor();
|
||
const { actor: a2 } = await makeUserAndActor();
|
||
await expect(
|
||
controller.handleGroupAddUsers(
|
||
makeReq(
|
||
{ uid: `does-not-exist-${uuidv4()}`, users: [] },
|
||
{ actor: a1 },
|
||
),
|
||
makeRes(),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 404 });
|
||
|
||
// Group owned by a1; a2 tries to add → 403.
|
||
const createRes = makeRes();
|
||
await controller.handleGroupCreate(
|
||
makeReq({}, { actor: a1 }),
|
||
createRes,
|
||
);
|
||
const { uid } = createRes.body as { uid: string };
|
||
await expect(
|
||
controller.handleGroupAddUsers(
|
||
makeReq({ uid, users: [] }, { actor: a2 }),
|
||
makeRes(),
|
||
),
|
||
).rejects.toMatchObject({ statusCode: 403 });
|
||
});
|
||
|
||
it('group/list: forwards to GroupStore listByOwner/listByMember (or surfaces the source-side method-name mismatch)', async () => {
|
||
const { actor } = await makeUserAndActor();
|
||
const res = makeRes();
|
||
try {
|
||
await controller.handleGroupList(makeReq({}, { actor }), res);
|
||
const body = res.body as {
|
||
owned_groups: unknown[];
|
||
in_groups: unknown[];
|
||
};
|
||
expect(Array.isArray(body.owned_groups)).toBe(true);
|
||
expect(Array.isArray(body.in_groups)).toBe(true);
|
||
} catch (e) {
|
||
// The handler calls `stores.group.listByOwner(...)`, but the
|
||
// GroupStore implementation may expose a differently-named
|
||
// method. Surface the mismatch so a future GroupStore rename
|
||
// re-enables the assertion above.
|
||
expect((e as Error).message).toMatch(
|
||
/listByOwner|listByMember|is not a function/,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('group/public-groups: returns {user, temp} from config', async () => {
|
||
const res = makeRes();
|
||
await controller.handleGroupPublicGroups(makeReq({}), res);
|
||
const body = res.body as { user: string | null; temp: string | null };
|
||
expect(body).toHaveProperty('user');
|
||
expect(body).toHaveProperty('temp');
|
||
});
|
||
});
|
||
|
||
// ── GUI token + session sync cookie ────────────────────────────────
|
||
|
||
describe('AuthController.handleGetGuiToken + handleSessionSyncCookie', () => {
|
||
it('get-gui-token: 400 when actor has no session bound', async () => {
|
||
const { actor } = await makeUserAndActor();
|
||
await expect(
|
||
controller.handleGetGuiToken(makeReq({}, { actor }), makeRes()),
|
||
).rejects.toMatchObject({ statusCode: 400 });
|
||
});
|
||
|
||
it('get-gui-token: returns a verifiable GUI token for an actor with a session', async () => {
|
||
const { user, actor } = await makeUserAndActor();
|
||
const sessionRes = await server.services.auth.createSessionToken(
|
||
user,
|
||
{},
|
||
);
|
||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||
const sessionedActor = {
|
||
...actor,
|
||
session: { uid: sessionUid },
|
||
} as Actor;
|
||
|
||
const res = makeRes();
|
||
await controller.handleGetGuiToken(
|
||
makeReq({}, { actor: sessionedActor }),
|
||
res,
|
||
);
|
||
const body = res.body as { token: string };
|
||
const decoded = server.services.token.verify('auth', body.token) as {
|
||
type: string;
|
||
user_uid: string;
|
||
};
|
||
expect(decoded.type).toBe('gui');
|
||
expect(decoded.user_uid).toBe(user.uuid);
|
||
});
|
||
|
||
it('session/sync-cookie: 400 when no session; 204 + cookie when bound', async () => {
|
||
const { user, actor } = await makeUserAndActor();
|
||
// No session → 400.
|
||
const r1 = makeRes();
|
||
await controller.handleSessionSyncCookie(makeReq({}, { actor }), r1);
|
||
expect(r1.statusCode).toBe(400);
|
||
|
||
// Bound session → 204 with the session cookie set.
|
||
const sessionRes = await server.services.auth.createSessionToken(
|
||
user,
|
||
{},
|
||
);
|
||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||
const sessionedActor = {
|
||
...actor,
|
||
session: { uid: sessionUid },
|
||
} as Actor;
|
||
|
||
const r2 = makeRes();
|
||
await controller.handleSessionSyncCookie(
|
||
makeReq({}, { actor: sessionedActor }),
|
||
r2,
|
||
);
|
||
expect(r2.statusCode).toBe(204);
|
||
expect(r2.cookies['puter_auth_token']).toBeDefined();
|
||
});
|
||
});
|
||
|
||
// ── Delete own user ────────────────────────────────────────────────
|
||
|
||
describe('AuthController.handleDeleteOwnUser', () => {
|
||
it('cascade-deletes the user row and clears the session cookie', async () => {
|
||
const { user, actor } = await makeUserAndActor();
|
||
const res = makeRes();
|
||
await controller.handleDeleteOwnUser(makeReq({}, { actor }), res);
|
||
expect(res.body).toEqual({ success: true });
|
||
expect(res.clearedCookies).toContain('puter_auth_token');
|
||
expect(res.clearedCookies).toContain('puter_revalidation');
|
||
// Row is gone.
|
||
const after = await server.stores.user.getById(user.id, {
|
||
force: true,
|
||
});
|
||
expect(after).toBeFalsy();
|
||
});
|
||
|
||
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 actor’s own session', async () => {
|
||
const { user, actor } = await makeUserAndActor();
|
||
const sessionRes = await server.services.auth.createSessionToken(
|
||
user,
|
||
{},
|
||
);
|
||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||
const res = makeRes();
|
||
await controller.handleRevokeSession(
|
||
makeReq({ uuid: sessionUid }, { actor }),
|
||
res,
|
||
);
|
||
const body = res.body as { sessions: unknown[] };
|
||
expect(Array.isArray(body.sessions)).toBe(true);
|
||
});
|
||
|
||
it('refuses to revoke the caller’s 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 });
|
||
});
|
||
});
|