- Followers/following lists (paginated), 'follows you', remove a follower - Blocking severs follows both ways and hides each user from the other (profile 404s, search, follows); the blocker sees activity hidden - Reports for moderation (App Store guideline 1.2) - First account on a server becomes admin; admins see the report queue, resolve reports, and suspend accounts (signs out, blocks login, hides the profile, stops sharing). set-role script to promote/demote - users.onboardedAt drives first-run onboarding; existing accounts are backfilled as already onboarded. Empty PATCH /me no longer 500s - Remove Spotify account sign-in and polling: Spotify caps new apps at 5 accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the Android reader. App-level catalog search stays for exact links - Shared: QR code generation and profile-link parsing (verified with jsQR) - Integration test timeout raised; password hashing is slow by design Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
106 lines
5.0 KiB
TypeScript
106 lines
5.0 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { sql } from '../src/db';
|
|
import { bearer, createUser, json, routeFetch, useTestApp } from './helpers';
|
|
|
|
const app = useTestApp();
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe('auth', () => {
|
|
it('registers, returns a token, and authenticates with bearer or cookie', async () => {
|
|
const res = await app().inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { username: 'Alice', email: ' alice@example.com ', password: 'password123' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const { user, token } = res.json();
|
|
expect(user).toMatchObject({ username: 'alice', email: 'alice@example.com', plan: 'free' });
|
|
expect(user).not.toHaveProperty('passwordHash');
|
|
|
|
const cookie = res.cookies.find((c) => c.name === 'ms_session');
|
|
expect(cookie).toMatchObject({ httpOnly: true, sameSite: 'Lax' });
|
|
|
|
const viaBearer = await app().inject({ url: '/me', headers: bearer(token) });
|
|
const viaCookie = await app().inject({ url: '/me', cookies: { ms_session: cookie!.value } });
|
|
expect(viaBearer.json().id).toBe(user.id);
|
|
expect(viaCookie.json().id).toBe(user.id);
|
|
});
|
|
|
|
it('stores only a hash of the session token', async () => {
|
|
const { token } = await createUser(app(), 'alice');
|
|
const rows = await sql`select id from sessions`;
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.id).not.toBe(token);
|
|
});
|
|
|
|
it('rejects duplicate usernames and emails', async () => {
|
|
await createUser(app(), 'alice');
|
|
const dupName = await app().inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { username: 'ALICE', email: 'other@example.com', password: 'password123' },
|
|
});
|
|
const dupEmail = await app().inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { username: 'other', email: 'Alice@Example.com', password: 'password123' },
|
|
});
|
|
expect(dupName.statusCode).toBe(409);
|
|
expect(dupName.json().error).toBe('Username taken');
|
|
expect(dupEmail.statusCode).toBe(409);
|
|
expect(dupEmail.json().error).toBe('Email already registered');
|
|
});
|
|
|
|
it('returns a readable 400 for invalid input', async () => {
|
|
const res = await app().inject({
|
|
method: 'POST',
|
|
url: '/auth/register',
|
|
payload: { username: 'al', email: 'a@b.co', password: 'password123' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().error).toMatch(/Username must be 3-32 characters/);
|
|
});
|
|
|
|
it('gives the same error for unknown email and wrong password', async () => {
|
|
await createUser(app(), 'alice');
|
|
const wrongPw = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'nope' } });
|
|
const noUser = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'ghost@example.com', password: 'nope' } });
|
|
expect(wrongPw.statusCode).toBe(401);
|
|
expect(noUser.statusCode).toBe(401);
|
|
expect(wrongPw.json()).toEqual(noUser.json());
|
|
});
|
|
|
|
it('logout invalidates the session', async () => {
|
|
const { token } = await createUser(app(), 'alice');
|
|
await app().inject({ method: 'POST', url: '/auth/logout', headers: bearer(token), payload: {} });
|
|
expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(401);
|
|
});
|
|
|
|
it('account deletion removes the user and everything attached', async () => {
|
|
const alice = await createUser(app(), 'alice');
|
|
const bob = await createUser(app(), 'bob');
|
|
await app().inject({ method: 'POST', url: '/users/bob/follow', headers: bearer(alice.token), payload: {} });
|
|
vi.stubGlobal('fetch', routeFetch([[/listenbrainz\.org/, () => json({ payload: { listens: [] } })]]));
|
|
const connected = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(alice.token), payload: { username: 'x' } });
|
|
expect(connected.statusCode).toBe(200);
|
|
|
|
const res = await app().inject({ method: 'DELETE', url: '/me', headers: bearer(alice.token) });
|
|
expect(res.statusCode).toBe(200);
|
|
expect((await app().inject({ url: '/me', headers: bearer(alice.token) })).statusCode).toBe(401);
|
|
const [counts] = await sql`select (select count(*)::int from follows) as follows, (select count(*)::int from connections) as connections`;
|
|
expect(counts).toEqual({ follows: 0, connections: 0 });
|
|
expect((await app().inject({ url: '/users/bob', headers: bearer(bob.token) })).json().followerCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('onboarding flag', () => {
|
|
it('new accounts start un-onboarded; finishing sets it once', async () => {
|
|
const { token, user } = await createUser(app(), 'newbie');
|
|
expect(user.onboardedAt).toBeNull();
|
|
const first = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json();
|
|
expect(first.onboardedAt).toEqual(expect.any(String));
|
|
const again = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json();
|
|
expect(again.onboardedAt).toBe(first.onboardedAt);
|
|
});
|
|
});
|