5de974edaf
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.
316 lines
9.5 KiB
TypeScript
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');
|
|
});
|
|
});
|