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.
This commit is contained in:
Keysat
2026-05-09 10:41:13 -05:00
parent 65f4b7a7c7
commit 5de974edaf
13 changed files with 5354 additions and 21 deletions
+2
View File
@@ -27,6 +27,8 @@ jobs:
run: npx prisma generate run: npx prisma generate
- name: Type-check - name: Type-check
run: npx tsc --noEmit run: npx tsc --noEmit
- name: Lint
run: npm run lint
- name: Tests - name: Tests
run: npm test run: npm test
+105
View File
@@ -0,0 +1,105 @@
# Changelog
All notable changes to Proof of Work and its StartOS package wrapper.
The format roughly follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versions track the StartOS package release rev (`upstream:rev` per ExVer).
## [Unreleased]
This is everything in `master` since the last published `.s9pk`. Will
ship as v1.0.0:1 once the maintainer drops a fresh `/data` snapshot
into `start9/0.4/seed/data/app.db` and runs `make x86 && make install`.
### Added
- **Multi-user support.** Every install starts with one admin
(`admin@local`) and sign-ups closed. The admin opens sign-ups via
Settings -> Instance Settings or via the new StartOS package action
"Set new signups". New users start with no admin privileges and the
full curated exercise library auto-seeded.
- **Curated exercise library** at `proof-of-work/prisma/exercises.seed.json`.
Used by `prisma/seed.ts` for fresh installs and by
`ensureExerciseLibrary.cjs` (run from `docker_entrypoint.sh` on every
boot) so library updates flow to existing installs additively, never
overwriting users' own custom exercises.
- `npm run sync-library` regenerates the JSON from the live snapshot.
- **In-app password change** at Settings -> Change password. Verifies
current password, requires 8+ char new password, auto-revokes every
other session for the user.
- **Admin user management** at `/main/admin/users`. List / promote /
demote / reset-password / delete with last-admin guard and self-delete
guard. Admin-initiated password reset force-revokes all the target's
sessions.
- **Self-serve account deletion** at Settings -> Danger Zone. Requires
current password AND typing the literal phrase "delete my account".
Refused for the last admin. Cascades through Prisma onDelete.
- **Last-login tracking** (`User.lastLoginAt`). Stamped on every session
creation, displayed as a relative-age cell in the admin Users table.
- **Export my data** at Settings -> Export my data. Downloads a JSON
with every workout, set, exercise, program tied to the user. Password
hash and sessions excluded.
- **Rate limits** on `/auth/login` (10/IP/15min) and `/auth/signup`
(5/IP/15min). In-process sliding window, no deps.
- **Security headers**: Content-Security-Policy (with frame-ancestors
'none', form-action 'self', object-src 'none'),
Strict-Transport-Security, Referrer-Policy, Permissions-Policy.
- **SQLite WAL mode** + `synchronous=NORMAL` enabled in entrypoint.
Keeps readers from blocking on a concurrent backup-time writer.
- **StartOS Package Action** `change-admin-credentials` now keys on
`WHERE isAdmin = 1 ORDER BY createdAt ASC LIMIT 1` (previously oldest
user regardless of role).
- **StartOS Package Action** `toggle-signups`: same setter as the
in-app admin toggle, accessible from the StartOS UI without an admin
login. Asserts read-back matches written value.
- **Test suite** (Vitest, 34 tests, ~2s): rate limit, hashing,
curated-library multi-user idempotency, admin actions including
last-admin guard, signup gate + email-enumeration leak check,
password-change, account deletion.
- **GitHub Actions CI**: app job runs prisma validate + prisma generate
+ tsc + lint + tests; startos job runs the package's `npm run check`
(tsc --noEmit). Both on push and PR to master/main.
- **ESLint config** (`.eslintrc.json` extending `next/core-web-vitals`).
Wired into CI.
- **Enriched `/api/health`** reports DB connection, journal mode (WAL
status), library JSON availability, instance signup state, and user
count. 503 if DB is unreachable; warnings field for non-fatal issues
(e.g. journal_mode != wal).
### Changed
- **Rebranded `workout-log` -> `proof-of-work`** end-to-end. Folder,
npm package name, StartOS package id. StartOS treats this as a brand
new service; cutover from the legacy package is via baked seed in
v1.0.0:1, not in-place upgrade.
- **Session tokens** now 256-bit `crypto.randomBytes` hex. Were derived
from `Math.random() + Date.now()` — predictable enough that a
determined attacker could enumerate other users' tokens. **Existing
sessions invalidate on upgrade by design** — token format/length
changed, old tokens won't validate.
- **Version graph reset** to `1.0.0:1` (was on the legacy `workout-log`
v0.1.0:18 / :19 / :20 line).
### Removed
- Legacy `start9/0.3.5/` package (StartOS 0.3.5 wrapper, 65MB image
artifacts, no longer the deploy target).
- `start9-example-packaging/` template from another project.
- Workout-planner standalone Dockerfile + docker-compose.yml (replaced
by the StartOS package's own Dockerfile).
- Various planning docs replaced by `start9/0.4/DEPLOY_040.md` and the
root `README.md`.
### Compat notes for cutover
The boot-time entrypoint runs idempotent ALTERs that:
- Add `User.isAdmin` and auto-promote the oldest user to admin if no
admin exists (preserves admin functionality across the cutover).
- Add `User.lastLoginAt`.
- Create the `InstanceSettings` table + singleton row (signupsOpen=0).
- Switch SQLite to WAL mode (persists in DB header thereafter).
So a snapshot pulled off the legacy `workout-log` host comes up as a
working multi-user `proof-of-work` install with no manual SQL.
---
## v0.1.0:17 and earlier (legacy `workout-log` package)
Out of scope for this changelog. The legacy package's history lives in
the git log; nothing in this repo references it after v1.0.0:1 ships.
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "next/core-web-vitals",
"ignorePatterns": [
".next/",
"node_modules/",
"tsconfig.tsbuildinfo",
"prisma/data/",
"scripts/",
"tests/",
"next-env.d.ts"
],
"rules": {
"react/no-unescaped-entities": "warn",
"@next/next/no-html-link-for-pages": "off"
}
}
+73 -17
View File
@@ -1,4 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import * as fs from "node:fs";
import * as path from "node:path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -6,31 +8,85 @@ export const revalidate = 0;
/** /**
* GET /api/health * GET /api/health
* Health check endpoint — verifies both the server and database are operational. *
* Used by StartOS health checks and Docker health checks. * Health check endpoint. Reports:
* Excluded from auth middleware in middleware.ts. * - status: 'ok' if every check passed, 'error' otherwise
* - database.connected: was the count query successful?
* - database.journalMode: 'wal' is the only acceptable value in
* production (entrypoint sets it on every boot)
* - users / instanceSettings.signupsOpen: cheap surface metadata
* - library.available: did exercises.seed.json load? Should be true
* in any properly-built image.
*
* Used by StartOS health checks (port-listening AND content checks)
* plus Docker HEALTHCHECK. Excluded from auth middleware.
*/ */
export async function GET() { export async function GET() {
try { const checks: Record<string, unknown> = {};
// Verify database connectivity with a lightweight query let httpStatus = 200;
const userCount = await prisma.user.count();
return NextResponse.json({ // ---- Database --------------------------------------------------------
status: "ok", try {
timestamp: Date.now(), const userCount = await prisma.user.count();
database: "connected", const settings = await prisma.instanceSettings.findUnique({
users: userCount, where: { id: 1 },
}); });
const journalRows = await prisma.$queryRawUnsafe<Array<{ journal_mode: string }>>(
'PRAGMA journal_mode;',
);
const journalMode = journalRows[0]?.journal_mode ?? 'unknown';
checks.database = {
connected: true,
journalMode,
walEnabled: journalMode === 'wal',
};
checks.users = userCount;
checks.instanceSettings = {
signupsOpen: settings?.signupsOpen ?? false,
};
if (journalMode !== 'wal') {
// WAL is required for safe online backups. Surface as a warning
// but don't fail the health check (the app still works).
checks.warnings = [
...((checks.warnings as string[]) ?? []),
`journal_mode is '${journalMode}', expected 'wal'`,
];
}
} catch (error) { } catch (error) {
// Server is up but database is unreachable or corrupted checks.database = {
connected: false,
error: error instanceof Error ? error.message : 'Unknown DB error',
};
httpStatus = 503;
}
// ---- Curated library file ------------------------------------------
try {
const candidates = [
path.resolve(process.cwd(), 'prisma/exercises.seed.json'),
path.resolve(process.cwd(), '../prisma/exercises.seed.json'),
];
const found = candidates.find((p) => fs.existsSync(p));
if (found) {
const stat = fs.statSync(found);
checks.library = { available: true, sizeBytes: stat.size };
} else {
checks.library = { available: false };
}
} catch (error) {
checks.library = {
available: false,
error: error instanceof Error ? error.message : 'unknown',
};
}
return NextResponse.json( return NextResponse.json(
{ {
status: "error", status: httpStatus === 200 ? 'ok' : 'error',
timestamp: Date.now(), timestamp: Date.now(),
database: "disconnected", ...checks,
error: error instanceof Error ? error.message : "Unknown database error",
}, },
{ status: 503 } { status: httpStatus },
); );
} }
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* GET /api/me/export
*
* Returns a JSON document containing every row this user owns:
* profile, preferences, custom exercises, workouts (with set logs),
* programs (and the nested weeks/days/exercises). The download omits
* the user's password hash and session tokens.
*
* The shape is intentionally a 1:1 dump of the underlying tables so
* a future "import my data into a different instance" feature can
* round-trip without ambiguity.
*/
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const [profile, exercises, workouts, programs] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
email: true,
name: true,
isAdmin: true,
createdAt: true,
lastLoginAt: true,
userPreferences: true,
},
}),
prisma.exercise.findMany({
where: { userId: user.id },
orderBy: [{ type: 'asc' }, { name: 'asc' }],
}),
prisma.workout.findMany({
where: { userId: user.id, deletedAt: null },
include: {
setLogs: {
orderBy: { setNumber: 'asc' },
},
},
orderBy: { date: 'desc' },
}),
prisma.program.findMany({
where: { userId: user.id },
include: {
weeks: {
include: {
days: {
include: { exercises: true },
},
},
},
},
}),
]);
const payload = {
schema: 'proof-of-work-export@1',
exportedAt: new Date().toISOString(),
profile,
exercises,
workouts,
programs,
counts: {
exercises: exercises.length,
workouts: workouts.length,
sets: workouts.reduce((sum, w) => sum + w.setLogs.length, 0),
programs: programs.length,
},
};
const date = new Date().toISOString().slice(0, 10);
const filename = `proof-of-work-${user.email.replace(/[^a-z0-9]/gi, '_')}-${date}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="${filename}"`,
'cache-control': 'no-store',
},
});
}
+2
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import SettingsForm from "@/components/settings/SettingsForm"; import SettingsForm from "@/components/settings/SettingsForm";
import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
import ExportMyData from "@/components/settings/ExportMyData";
import DangerZone from "@/components/settings/DangerZone"; import DangerZone from "@/components/settings/DangerZone";
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
import { getInstanceSettings } from "@/lib/instanceSettings"; import { getInstanceSettings } from "@/lib/instanceSettings";
@@ -29,6 +30,7 @@ export default async function SettingsPage() {
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8"> <div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
<SettingsForm user={user} /> <SettingsForm user={user} />
<ChangePasswordForm /> <ChangePasswordForm />
<ExportMyData />
{user.isAdmin && instanceSettings && ( {user.isAdmin && instanceSettings && (
<AdminInstanceSettings <AdminInstanceSettings
initialSignupsOpen={instanceSettings.signupsOpen} initialSignupsOpen={instanceSettings.signupsOpen}
@@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
export default function ExportMyData() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleClick = async () => {
setError(null);
setBusy(true);
try {
const res = await fetch('/api/me/export');
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Filename comes from Content-Disposition; let the browser pick it.
const cd = res.headers.get('content-disposition');
const match = cd?.match(/filename="([^"]+)"/);
a.download = match?.[1] ?? 'proof-of-work-export.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
return (
<section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4">
<header>
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Export my data
</h2>
<p className="text-xs text-zinc-500 mt-1">
Download a JSON file with every workout, set, exercise, and
program tied to your account. Password and sessions are not
included.
</p>
</header>
<button
type="button"
onClick={handleClick}
disabled={busy}
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 disabled:opacity-50"
>
{busy ? 'Building export...' : 'Download JSON'}
</button>
{error && (
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
{error}
</div>
)}
</section>
);
}
+4444
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -37,6 +37,8 @@
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.35",
"prisma": "^5.0.0", "prisma": "^5.0.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
+191
View File
@@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// next/cache writes a no-op tag store outside a request — just stub it.
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));
// `getCurrentUser` reads cookies. Replace it with a controllable stub.
// vi.hoisted is required so the mock fn is available when vi.mock's
// factory runs (vitest hoists vi.mock to the top of the file).
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
import { prisma } from '@/lib/prisma';
import {
setUserAdmin,
adminResetPassword,
deleteUser,
} from '@/app/main/admin/users/actions';
async function makeUser(opts: {
email: string;
isAdmin?: boolean;
passwordHash?: string;
}) {
return prisma.user.create({
data: {
email: opts.email,
passwordHash: opts.passwordHash ?? 'fake-hash',
isAdmin: opts.isAdmin ?? false,
},
});
}
beforeEach(async () => {
// Wipe everything between tests for isolation. SetLog/Workout/Session
// are FK'd to User with onDelete: Cascade, so deleting users sweeps
// most of it; we still nuke InstanceSettings for a clean slate.
await prisma.session.deleteMany();
await prisma.exercise.deleteMany();
await prisma.workout.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
describe('setUserAdmin', () => {
it('rejects non-admin actor with Forbidden error', async () => {
const target = await makeUser({ email: 't@x' });
const actor = await makeUser({ email: 'a@x', isAdmin: false });
getCurrentUserMock.mockResolvedValue(actor);
const res = await setUserAdmin(target.id, true);
expect(res.error).toBe('Forbidden');
});
it('promotes a regular user to admin', async () => {
const admin = await makeUser({ email: 'a@x', isAdmin: true });
const target = await makeUser({ email: 't@x', isAdmin: false });
getCurrentUserMock.mockResolvedValue(admin);
const res = await setUserAdmin(target.id, true);
expect(res.success).toBe(true);
const after = await prisma.user.findUnique({ where: { id: target.id } });
expect(after?.isAdmin).toBe(true);
});
it('refuses to demote the LAST admin', async () => {
const onlyAdmin = await makeUser({ email: 'a@x', isAdmin: true });
getCurrentUserMock.mockResolvedValue(onlyAdmin);
const res = await setUserAdmin(onlyAdmin.id, false);
expect(res.error).toMatch(/last admin/i);
const after = await prisma.user.findUnique({
where: { id: onlyAdmin.id },
});
expect(after?.isAdmin).toBe(true);
});
it('allows demoting an admin when at least one other admin exists', async () => {
const a1 = await makeUser({ email: 'a1@x', isAdmin: true });
const a2 = await makeUser({ email: 'a2@x', isAdmin: true });
getCurrentUserMock.mockResolvedValue(a1);
const res = await setUserAdmin(a2.id, false);
expect(res.success).toBe(true);
const after = await prisma.user.findUnique({ where: { id: a2.id } });
expect(after?.isAdmin).toBe(false);
});
});
describe('deleteUser', () => {
it('refuses to delete the last admin', async () => {
const a1 = await makeUser({ email: 'a1@x', isAdmin: true });
const target = await makeUser({ email: 'a2@x', isAdmin: true });
// Delete a1 first via separate path to leave only `target` admin.
await prisma.user.delete({ where: { id: a1.id } });
const a3 = await makeUser({ email: 'a3@x', isAdmin: true });
getCurrentUserMock.mockResolvedValue(a3);
// Now actor a3 + target = 2 admins. Delete target => 1 admin left = ok.
const res1 = await deleteUser(target.id);
expect(res1.success).toBe(true);
// Now only a3 admin remains. Try deleting another (non-admin) for setup.
const u4 = await makeUser({ email: 'u4@x' });
expect((await deleteUser(u4.id)).success).toBe(true);
// a3 trying to delete itself is blocked by the self-delete guard,
// not the last-admin guard, but verify the message.
const selfRes = await deleteUser(a3.id);
expect(selfRes.error).toMatch(/own account/i);
});
it('refuses self-deletion via admin UI', async () => {
const a = await makeUser({ email: 'a@x', isAdmin: true });
await makeUser({ email: 'b@x', isAdmin: true }); // satisfies last-admin guard
getCurrentUserMock.mockResolvedValue(a);
const res = await deleteUser(a.id);
expect(res.error).toMatch(/own account/i);
});
it('cascades deletion to the user\'s exercises and workouts', async () => {
const admin = await makeUser({ email: 'a@x', isAdmin: true });
const target = await makeUser({ email: 't@x' });
await prisma.exercise.create({
data: {
userId: target.id,
name: 'Test exercise',
muscleGroups: '[]',
type: 'custom',
},
});
await prisma.workout.create({
data: { userId: target.id, date: new Date() },
});
getCurrentUserMock.mockResolvedValue(admin);
const res = await deleteUser(target.id);
expect(res.success).toBe(true);
expect(
await prisma.exercise.count({ where: { userId: target.id } }),
).toBe(0);
expect(
await prisma.workout.count({ where: { userId: target.id } }),
).toBe(0);
});
});
describe('adminResetPassword', () => {
it('hashes and stores a new password and revokes existing sessions', async () => {
const admin = await makeUser({ email: 'a@x', isAdmin: true });
const target = await makeUser({ email: 't@x', passwordHash: 'old-hash' });
await prisma.session.createMany({
data: [
{ userId: target.id, token: 'sess-1', expiresAt: new Date(Date.now() + 1e9) },
{ userId: target.id, token: 'sess-2', expiresAt: new Date(Date.now() + 1e9) },
],
});
getCurrentUserMock.mockResolvedValue(admin);
const res = await adminResetPassword(target.id, 'newpassword123');
expect(res.success).toBe(true);
expect(res.revoked).toBe(2);
const after = await prisma.user.findUnique({ where: { id: target.id } });
expect(after?.passwordHash).not.toBe('old-hash');
expect(after?.passwordHash.startsWith('$2')).toBe(true);
const remaining = await prisma.session.count({
where: { userId: target.id },
});
expect(remaining).toBe(0);
});
it('rejects passwords shorter than 8 chars', async () => {
const admin = await makeUser({ email: 'a@x', isAdmin: true });
const target = await makeUser({ email: 't@x' });
getCurrentUserMock.mockResolvedValue(admin);
const res = await adminResetPassword(target.id, 'short');
expect(res.error).toMatch(/8 characters/);
});
});
+315
View File
@@ -0,0 +1,315 @@
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');
});
});
@@ -0,0 +1,43 @@
/**
* Setup file loaded before any test that imports server actions.
*
* Reasons:
* 1. Server actions reach for `lib/prisma` which instantiates a global
* PrismaClient against `DATABASE_URL` at module load. Set the env
* to a per-test-file SQLite DB BEFORE the import chain wakes Prisma
* up, so the actions hit our isolated test DB.
* 2. `prisma db push` once at startup applies the current schema to
* that DB so action queries succeed.
*
* Used as `setupFiles` in vitest.config.ts.
*/
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
const dbPath = path.resolve(
'/tmp',
`pow-actions-${process.pid}-${Date.now()}.db`,
);
process.env.DATABASE_URL = `file:${dbPath}`;
execFileSync(
'npx',
['prisma', 'db', 'push', '--skip-generate', '--accept-data-loss'],
{ env: process.env, stdio: 'pipe' },
);
// Expose the path so tests can clean rows between cases or peek at the
// raw DB file if they need to.
;(globalThis as any).__POW_TEST_DB__ = dbPath;
// Best-effort cleanup when the worker exits.
process.on('exit', () => {
for (const ext of ['', '-journal', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + ext);
} catch {
/* */
}
}
});
+4
View File
@@ -12,6 +12,10 @@ export default defineConfig({
// resolution. // resolution.
pool: 'forks', pool: 'forks',
forks: { singleFork: true }, forks: { singleFork: true },
// Action tests need DATABASE_URL set + schema pushed before the
// first prisma import in lib/prisma. setupFiles run before each
// test file's imports.
setupFiles: ['tests/helpers/setup-actions.ts'],
}, },
resolve: { resolve: {
alias: { alias: {