CI / check (push) Canceled after 0s
"Forgot your password?" on web and mobile sends a one-time link (1 hour) to the account's email; the website's reset page sets the new password, signs out every other session and signs in. The request responds the same whether or not the email has an account, and the email goes out in the background so timing doesn't reveal it either. Only a hash of the link token is stored. Sends through SMTP_* settings; without them, emails are logged (and captured in tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
73 lines
3.1 KiB
TypeScript
73 lines
3.1 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { afterAll, beforeAll, beforeEach } from 'vitest';
|
|
import type { AuthResponse } from '@musicshare/shared';
|
|
import { buildApp } from '../src/app';
|
|
import { sql } from '../src/db';
|
|
import { env } from '../src/env';
|
|
import { closeConnections } from '../src/lib/lifecycle';
|
|
import { redis } from '../src/lib/redis';
|
|
|
|
function assertTestTargets() {
|
|
// These wipe data: make it impossible to point them at a dev or prod database.
|
|
if (!new URL(env.DATABASE_URL).pathname.endsWith('_test')) throw new Error('DATABASE_URL is not a *_test database');
|
|
if (new URL(env.REDIS_URL).pathname.replace('/', '') === '' || new URL(env.REDIS_URL).pathname === '/0') {
|
|
throw new Error('REDIS_URL must select a non-default DB index for tests (e.g. redis://host:6379/1)');
|
|
}
|
|
}
|
|
|
|
export async function resetState() {
|
|
assertTestTargets();
|
|
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets restart identity cascade`;
|
|
await redis.flushdb();
|
|
}
|
|
|
|
/**
|
|
* Per-file setup: a fresh app (rate limits off unless asked) and clean state
|
|
* before each test. Returns a getter because the app is built in beforeAll.
|
|
*/
|
|
export function useTestApp(opts: { rateLimits?: boolean } = {}) {
|
|
let app: FastifyInstance;
|
|
beforeAll(async () => {
|
|
app = await buildApp({ logger: false, rateLimits: opts.rateLimits ?? false });
|
|
await app.ready();
|
|
});
|
|
beforeEach(resetState);
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await closeConnections();
|
|
});
|
|
return () => app;
|
|
}
|
|
|
|
export const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
|
|
export async function createUser(app: FastifyInstance, username: string): Promise<AuthResponse> {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { username, email: `${username}@example.com`, password: 'password123' },
|
|
});
|
|
if (res.statusCode !== 200) throw new Error(`register ${username} failed: ${res.body}`);
|
|
return res.json();
|
|
}
|
|
|
|
export async function follow(app: FastifyInstance, token: string, username: string) {
|
|
const res = await app.inject({ method: 'POST', url: `/users/${username}/follow`, headers: bearer(token), payload: {} });
|
|
if (res.statusCode !== 200) throw new Error(`follow ${username} failed: ${res.body}`);
|
|
}
|
|
|
|
type FetchRoute = [match: RegExp, respond: (url: string, init?: RequestInit) => Response | Promise<Response>];
|
|
|
|
/** Replace global fetch with a router; unmatched requests fail loudly so tests never hit the network. */
|
|
export function routeFetch(routes: FetchRoute[]) {
|
|
return async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
const route = routes.find(([re]) => re.test(url));
|
|
if (!route) throw new Error(`Unexpected fetch in test: ${url}`);
|
|
return route[1](url, init);
|
|
};
|
|
}
|
|
|
|
export const json = (body: unknown, status = 200) =>
|
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|