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
|
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
@@ -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 { 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 },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+4444
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
// 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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user