Files
proof-of-work/proof-of-work/tests/actions-auth.test.ts
T
Keysat 5de974edaf ESLint, server-action tests, export-my-data, enriched healthcheck, CHANGELOG
ESLint
- Pinned eslint@^8 + eslint-config-next@^14 to match Next 14's `next lint`.
  ESLint 9's flat-config breaks `next lint` for legacy projects.
- .eslintrc.json extends next/core-web-vitals; ignores tests/, scripts/,
  prisma/data/, .next/, node_modules.
- 7 pre-existing warnings surfaced (exhaustive-deps + alt-text + img tag
  in user-written components). Left as warnings — pre-existing, not
  breaking. CI runs lint; warnings don't fail the job.

Server action tests (tests/actions-admin.test.ts, tests/actions-auth.test.ts)
- Vitest setup file (tests/helpers/setup-actions.ts) sets DATABASE_URL
  to a per-process temp SQLite DB and runs `prisma db push` BEFORE
  lib/prisma instantiates its global PrismaClient. Tests then call the
  real server actions against an isolated DB.
- vi.mock + vi.hoisted to mock @/lib/auth.getCurrentUser, next/headers
  cookies+headers, next/navigation redirect, next/cache revalidatePath.
- Coverage:
  - admin: setUserAdmin (Forbidden, promote, last-admin demote refused,
    demote-with-other-admin allowed), deleteUser (last-admin guard,
    self-delete refused, cascading delete to exercises + workouts),
    adminResetPassword (hash-and-revoke, short-password rejected).
  - auth flows: signupAction (closed by default, opens-and-creates,
    mismatched confirm rejected, short pwd rejected, malformed email
    rejected, no email-enumeration leak), changePasswordAction
    (rotate-and-revoke-others, wrong current pwd rejected, no-op pwd
    rejected), deleteMyAccountAction (phrase required, password required,
    last-admin refused, success cascades + clears cookie + redirects).
- Total suite: 34 tests, ~2s.

Export my data (/api/me/export + Settings -> Export my data)
- Downloads a JSON dump of every workout/set/exercise/program tied to
  the user. Excludes password hash and sessions. Filename includes
  email + date. content-disposition: attachment, no-store cache.
- Exported shape matches the underlying tables 1:1 so a future "import
  my data" flow can round-trip without ambiguity.

Enriched /api/health
- Now reports: database.connected, database.journalMode (and walEnabled
  shortcut), users count, instanceSettings.signupsOpen, library.available
  + sizeBytes. Surfaces a `warnings` array if journal_mode != 'wal' but
  doesn't fail the check (app still works without WAL — just unsafe for
  online backups). Returns 503 only on hard DB failure.

CHANGELOG.md
- Single Unreleased section documenting everything that will ship as
  v1.0.0:1 once the maintainer drops a fresh /data snapshot. Added /
  Changed / Removed / Compat-notes sections.
2026-05-09 10:41:13 -05:00

316 lines
9.5 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock next/headers chain (cookies + headers used by the actions).
const { cookieStoreMock, headersStoreMock } = vi.hoisted(() => {
const cookies = new Map<string, string>();
return {
cookieStoreMock: {
get: vi.fn((name: string) =>
cookies.has(name) ? { name, value: cookies.get(name)! } : undefined,
),
set: vi.fn((name: string, value: string) => {
cookies.set(name, value);
}),
delete: vi.fn((name: string) => {
cookies.delete(name);
}),
_underlying: cookies,
},
headersStoreMock: new Headers({ 'x-forwarded-for': '127.0.0.1' }),
};
});
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => cookieStoreMock),
headers: vi.fn(async () => headersStoreMock),
}));
vi.mock('next/navigation', () => ({
redirect: vi.fn((dest: string) => {
throw new Error(`__redirect:${dest}`);
}),
}));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { prisma } from '@/lib/prisma';
import { hashPassword } from '@/lib/auth';
import { signupAction } from '@/app/auth/signup/actions';
import { changePasswordAction } from '@/app/main/settings/changePasswordAction';
import { deleteMyAccountAction } from '@/app/main/settings/deleteAccountAction';
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.exercise.deleteMany();
await prisma.workout.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
cookieStoreMock._underlying.clear();
cookieStoreMock.get.mockClear();
cookieStoreMock.set.mockClear();
cookieStoreMock.delete.mockClear();
// Each test file gets its own per-IP rate-limit bucket key by virtue
// of fresh module load via vitest forks. Within a single test file we
// need to space out signup attempts across cases — easy because each
// test uses a different email prefix and the rate limiter is per-IP,
// not per-email. Bumping the IP per test sidesteps the cap entirely.
headersStoreMock.set(
'x-forwarded-for',
`10.0.0.${Math.floor(Math.random() * 255)}`,
);
});
describe('signupAction', () => {
it('refuses when sign-ups are closed (default)', async () => {
await prisma.instanceSettings.create({
data: { id: 1, signupsOpen: false },
});
const res = await signupAction(
'new@example.com',
'password123',
'password123',
);
expect(res.error).toMatch(/not enabled/i);
expect(await prisma.user.count()).toBe(0);
});
it('creates a new user, seeds preferences, and sets a session cookie when open', async () => {
await prisma.instanceSettings.create({
data: { id: 1, signupsOpen: true },
});
const res = await signupAction(
'fresh@example.com',
'password123',
'password123',
'Fresh User',
);
expect(res.success).toBe(true);
const user = await prisma.user.findUnique({
where: { email: 'fresh@example.com' },
include: { userPreferences: true },
});
expect(user).toBeTruthy();
expect(user?.isAdmin).toBe(false);
expect(user?.name).toBe('Fresh User');
expect(user?.userPreferences).toBeTruthy();
expect(cookieStoreMock.set).toHaveBeenCalledWith(
'sessionToken',
expect.any(String),
expect.any(Object),
);
});
it('rejects mismatched password confirmation', async () => {
await prisma.instanceSettings.create({
data: { id: 1, signupsOpen: true },
});
const res = await signupAction('a@b.co', 'password123', 'different123');
expect(res.error).toMatch(/do not match/i);
expect(await prisma.user.count()).toBe(0);
});
it('rejects passwords shorter than 8 chars', async () => {
await prisma.instanceSettings.create({
data: { id: 1, signupsOpen: true },
});
const res = await signupAction('a@b.co', 'short', 'short');
expect(res.error).toMatch(/8 characters/i);
});
it('rejects malformed emails', async () => {
await prisma.instanceSettings.create({
data: { id: 1, signupsOpen: true },
});
const res = await signupAction(
'not-an-email',
'password123',
'password123',
);
expect(res.error).toMatch(/valid email/i);
});
it('does not leak existing-email signal in error message', async () => {
await prisma.instanceSettings.create({
data: { id: 1, signupsOpen: true },
});
await prisma.user.create({
data: {
email: 'taken@example.com',
passwordHash: await hashPassword('whatever123'),
},
});
const res = await signupAction(
'taken@example.com',
'password123',
'password123',
);
expect(res.error).toBeDefined();
// Generic message — no "already exists" / "in use" wording.
expect(res.error).not.toMatch(/exist|use|taken|already/i);
});
});
describe('changePasswordAction', () => {
let userId: string;
let token: string;
beforeEach(async () => {
const u = await prisma.user.create({
data: {
email: 'me@example.com',
passwordHash: await hashPassword('original123'),
},
});
userId = u.id;
token = 'session-token-current';
await prisma.session.create({
data: { userId, token, expiresAt: new Date(Date.now() + 1e9) },
});
cookieStoreMock._underlying.set('sessionToken', token);
});
it('rotates the password and revokes other sessions', async () => {
await prisma.session.createMany({
data: [
{ userId, token: 'other-1', expiresAt: new Date(Date.now() + 1e9) },
{ userId, token: 'other-2', expiresAt: new Date(Date.now() + 1e9) },
],
});
const res = await changePasswordAction(
'original123',
'newpassword123',
'newpassword123',
);
expect(res.success).toBe(true);
expect(res.revoked).toBe(2);
const remaining = await prisma.session.findMany({
where: { userId },
select: { token: true },
});
expect(remaining).toEqual([{ token }]);
});
it('rejects an incorrect current password', async () => {
const res = await changePasswordAction(
'wrongpassword',
'newpassword123',
'newpassword123',
);
expect(res.error).toMatch(/current password/i);
});
it('rejects when new equals current', async () => {
const res = await changePasswordAction(
'original123',
'original123',
'original123',
);
expect(res.error).toMatch(/differ/i);
});
});
describe('deleteMyAccountAction', () => {
it('refuses without the typed confirmation phrase', async () => {
const u = await prisma.user.create({
data: {
email: 'del@example.com',
passwordHash: await hashPassword('mypassword123'),
},
});
await prisma.session.create({
data: {
userId: u.id,
token: 'tok',
expiresAt: new Date(Date.now() + 1e9),
},
});
cookieStoreMock._underlying.set('sessionToken', 'tok');
const res = await deleteMyAccountAction('mypassword123', 'i agree');
expect(res?.error).toMatch(/exact phrase/i);
expect(await prisma.user.findUnique({ where: { id: u.id } })).toBeTruthy();
});
it('refuses with wrong password even when phrase is correct', async () => {
const u = await prisma.user.create({
data: {
email: 'del2@example.com',
passwordHash: await hashPassword('mypassword123'),
},
});
await prisma.session.create({
data: {
userId: u.id,
token: 'tok',
expiresAt: new Date(Date.now() + 1e9),
},
});
cookieStoreMock._underlying.set('sessionToken', 'tok');
const res = await deleteMyAccountAction('wrong-pwd', 'delete my account');
expect(res?.error).toMatch(/incorrect/i);
expect(await prisma.user.findUnique({ where: { id: u.id } })).toBeTruthy();
});
it('refuses when user is the last admin', async () => {
const u = await prisma.user.create({
data: {
email: 'lonely-admin@example.com',
passwordHash: await hashPassword('mypassword123'),
isAdmin: true,
},
});
await prisma.session.create({
data: {
userId: u.id,
token: 'tok',
expiresAt: new Date(Date.now() + 1e9),
},
});
cookieStoreMock._underlying.set('sessionToken', 'tok');
const res = await deleteMyAccountAction(
'mypassword123',
'delete my account',
);
expect(res?.error).toMatch(/last admin/i);
expect(await prisma.user.findUnique({ where: { id: u.id } })).toBeTruthy();
});
it('deletes account + cascades + clears cookie when correct', async () => {
const u = await prisma.user.create({
data: {
email: 'goodbye@example.com',
passwordHash: await hashPassword('mypassword123'),
},
});
await prisma.session.create({
data: {
userId: u.id,
token: 'tok',
expiresAt: new Date(Date.now() + 1e9),
},
});
await prisma.workout.create({
data: { userId: u.id, date: new Date() },
});
cookieStoreMock._underlying.set('sessionToken', 'tok');
// The action throws via `redirect()` on success — our mock redirect
// throws a recognizable string. Catch it.
let redirected = false;
try {
await deleteMyAccountAction('mypassword123', 'delete my account');
} catch (e) {
if ((e as Error).message.startsWith('__redirect:')) {
redirected = true;
} else {
throw e;
}
}
expect(redirected).toBe(true);
expect(await prisma.user.findUnique({ where: { id: u.id } })).toBeNull();
expect(await prisma.workout.count()).toBe(0);
expect(cookieStoreMock.delete).toHaveBeenCalledWith('sessionToken');
});
});