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:
@@ -27,6 +27,8 @@ jobs:
|
||||
run: npx prisma generate
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
|
||||
+105
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -6,31 +8,85 @@ export const revalidate = 0;
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Health check endpoint — verifies both the server and database are operational.
|
||||
* Used by StartOS health checks and Docker health checks.
|
||||
* Excluded from auth middleware in middleware.ts.
|
||||
*
|
||||
* Health check endpoint. Reports:
|
||||
* - 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() {
|
||||
try {
|
||||
// Verify database connectivity with a lightweight query
|
||||
const userCount = await prisma.user.count();
|
||||
const checks: Record<string, unknown> = {};
|
||||
let httpStatus = 200;
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: Date.now(),
|
||||
database: "connected",
|
||||
users: userCount,
|
||||
// ---- Database --------------------------------------------------------
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
const settings = await prisma.instanceSettings.findUnique({
|
||||
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) {
|
||||
// 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(
|
||||
{
|
||||
status: "error",
|
||||
status: httpStatus === 200 ? 'ok' : 'error',
|
||||
timestamp: Date.now(),
|
||||
database: "disconnected",
|
||||
error: error instanceof Error ? error.message : "Unknown database error",
|
||||
...checks,
|
||||
},
|
||||
{ status: 503 }
|
||||
{ status: httpStatus },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import SettingsForm from "@/components/settings/SettingsForm";
|
||||
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
||||
import ExportMyData from "@/components/settings/ExportMyData";
|
||||
import DangerZone from "@/components/settings/DangerZone";
|
||||
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
||||
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">
|
||||
<SettingsForm user={user} />
|
||||
<ChangePasswordForm />
|
||||
<ExportMyData />
|
||||
{user.isAdmin && instanceSettings && (
|
||||
<AdminInstanceSettings
|
||||
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>
|
||||
);
|
||||
}
|
||||
Generated
+4444
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,8 @@
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.35",
|
||||
"prisma": "^5.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,10 @@ export default defineConfig({
|
||||
// resolution.
|
||||
pool: 'forks',
|
||||
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: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user