Typed Prisma queries, bcrypt native, CSP nonces, /api/me/import, more tests
Typed Prisma queries
- where: any in app/api/workouts/route.ts (GET + POST) and
lib/db/workouts.ts replaced with Prisma.WorkoutWhereInput +
Prisma.WorkoutCreateInput + Prisma.DateTimeFilter. Catches typos
at compile time and surfaces query shape directly in tooltips.
Workout import endpoint tests (tests/routes-import.test.ts)
- 7 tests covering /api/workouts/import/save: 401 unauthenticated,
empty workouts rejected, case-insensitive name matching against
existing exercises, new-exercise creation with isCustom=true and
type='other' default, explicit existingExerciseId honored over
name lookup, multiple workouts per call, sequential setNumber
per exercise per workout.
bcryptjs -> bcrypt (native)
- Roughly 10x faster than the pure-JS implementation under load —
login latency drops from ~250ms to ~25ms. Hash format is fully
cross-compatible with bcryptjs ($2a$ / $2b$ both verify), so
existing user passwords keep working without migration.
- Dockerfile builder stage adds python3 + make + g++ as a safety net
for native node-gyp compilation on alpine when prebuilt binaries
aren't available.
- Runner stage explicitly COPYs node_modules/bcrypt so the .node
binding is unambiguously present even if Next.js standalone
tracing somehow misses it.
- StartOS package's changeAdminCredentials.ts keeps bcryptjs (it's
bundled by ncc into a single JS file and runs only on the rare
admin action; native bcrypt would require shipping the .node
binding through ncc which it doesn't handle gracefully).
CSP nonces (middleware.ts + next.config.js)
- Per-request nonce generated in middleware. Forwarded to Next via
the x-nonce request header, which Next 13.4+ automatically stamps
onto its inline bootstrap scripts. CSP response header includes
`'nonce-${nonce}' 'strict-dynamic'`, dropping the previous
`'unsafe-inline'` from script-src.
- Static CSP removed from next.config.js (middleware-set headers
override static ones, so keeping both was redundant).
- Middleware matcher widened to all paths except static assets so
the CSP applies to every page response. Existing /main + /api
auth gating preserved.
- style-src keeps 'unsafe-inline' — Next/Tailwind still inject
critical inline <style>; tightening that requires hash-based
style-src or per-style nonce stamping (Next doesn't auto-do
either). Worth a follow-up if you want the cleanest possible CSP.
/api/me/import (mirror of /api/me/export)
- Accepts the same JSON shape /api/me/export emits (schema string
validated: only `proof-of-work-export@1` accepted today).
- mode: 'merge' (default) — adds imported rows; existing exercises
with matching names are NOT overwritten (the user's custom version
wins). All workout sets with a known exercise get rebound to the
user's actual exercise id via name lookup.
- mode: 'replace' — wipes the user's exercises/workouts/sets first,
then imports. Requires `confirm: "REPLACE"` in the body.
- Always scoped to the actor — never touches other users' data.
- Profile/admin flag/sessions/InstanceSettings deliberately not
imported (account identity stays put).
- 7 tests cover: 401, schema rejection, merge create+skip, replace
confirmation gate, replace wipes-then-imports, isolation across
users.
- ExportMyData component grew Import (merge) + Import (replace)
buttons with native browser confirm() before the destructive
replace.
Test suite now 81 tests across 9 files in ~2.6s.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
/**
|
||||
* POST /api/me/import
|
||||
*
|
||||
* Restore a previously-exported account dump (from /api/me/export) into
|
||||
* the CURRENT user's account. Use cases:
|
||||
* - Move from one Proof of Work instance to another (export there,
|
||||
* import here).
|
||||
* - Disaster recovery from a known-good export file when the live
|
||||
* /data is gone.
|
||||
*
|
||||
* Behavior:
|
||||
* - Mode 'merge' (default): existing data stays put. Imported
|
||||
* exercises with names that already exist for this user are
|
||||
* skipped (no overwrite); workouts and sets are always added as
|
||||
* new rows with fresh IDs.
|
||||
* - Mode 'replace': hard-deletes everything the current user owns
|
||||
* (workouts, sets, exercises, programs, preferences) before
|
||||
* inserting the imported data. Profile + admin flag are
|
||||
* preserved. Requires `confirm: "REPLACE"` in the body.
|
||||
*
|
||||
* What is NOT imported:
|
||||
* - User identity (email, password, isAdmin) — the rows always go to
|
||||
* the actor's id.
|
||||
* - Sessions — never round-tripped; an import shouldn't grant new
|
||||
* login tokens.
|
||||
* - InstanceSettings — instance-wide, not account-owned.
|
||||
*
|
||||
* The export schema string is checked: only `proof-of-work-export@1`
|
||||
* is accepted today. Future schema bumps must add migration code here.
|
||||
*/
|
||||
|
||||
const exerciseImport = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
type: z.string(),
|
||||
muscleGroups: z.string(), // already JSON-stringified in export
|
||||
inputFields: z.string().optional(),
|
||||
defaultWeightUnit: z.string().nullable().optional(),
|
||||
isCustom: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const setLogImport = z.object({
|
||||
setNumber: z.number().int().positive(),
|
||||
reps: z.number().int().nullable().optional(),
|
||||
weight: z.number().nullable().optional(),
|
||||
weightUnit: z.string().optional(),
|
||||
rpe: z.number().int().nullable().optional(),
|
||||
durationSeconds: z.number().int().nullable().optional(),
|
||||
distance: z.number().nullable().optional(),
|
||||
distanceUnit: z.string().nullable().optional(),
|
||||
calories: z.number().int().nullable().optional(),
|
||||
customMetrics: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
// The exported set carries an exerciseId pointing into the export's
|
||||
// own exercise list, but NOT a stable id we can FK on after import.
|
||||
// We re-resolve via the parent workout's exercise-name lookup below.
|
||||
exerciseId: z.string(),
|
||||
});
|
||||
|
||||
const workoutImport = z.object({
|
||||
date: z.string(), // ISO
|
||||
name: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
durationMinutes: z.number().int().nullable().optional(),
|
||||
difficulty: z.number().int().nullable().optional(),
|
||||
caloriesBurned: z.number().int().nullable().optional(),
|
||||
setLogs: z.array(setLogImport),
|
||||
});
|
||||
|
||||
const importPayload = z.object({
|
||||
schema: z.literal('proof-of-work-export@1'),
|
||||
exercises: z.array(exerciseImport),
|
||||
workouts: z.array(workoutImport),
|
||||
});
|
||||
|
||||
const requestBody = z.object({
|
||||
payload: importPayload,
|
||||
mode: z.enum(['merge', 'replace']).default('merge'),
|
||||
confirm: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = requestBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid import payload', details: parsed.error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const { payload, mode, confirm } = parsed.data;
|
||||
|
||||
if (mode === 'replace' && confirm !== 'REPLACE') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Replace mode requires `confirm: "REPLACE"` in the request body. This deletes every workout/exercise/program owned by your account before importing.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build an export-id -> exercise-name map so set logs can be
|
||||
// rebound to the freshly-created exercises after merge/replace.
|
||||
// The export's `id` field is on each row but Zod's exercise schema
|
||||
// doesn't include id (we don't need it as a column to insert).
|
||||
// Read it from the raw payload to preserve the FK linkage between
|
||||
// the export's setLogs.exerciseId and its exercises[].id.
|
||||
const exportExerciseIdToName = new Map<string, string>();
|
||||
const rawExercises = (body?.payload?.exercises ?? []) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
for (const ex of rawExercises) {
|
||||
if (typeof ex?.id === 'string' && typeof ex?.name === 'string') {
|
||||
exportExerciseIdToName.set(ex.id, ex.name);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
if (mode === 'replace') {
|
||||
// Order matters because of FK cascades — explicit deletes
|
||||
// are still cleanest to avoid relying on cascade semantics.
|
||||
await tx.setLog.deleteMany({
|
||||
where: { workout: { userId: user.id } },
|
||||
});
|
||||
await tx.workout.deleteMany({ where: { userId: user.id } });
|
||||
await tx.exercise.deleteMany({ where: { userId: user.id } });
|
||||
}
|
||||
|
||||
// ---- Exercises: upsert by (userId, name) -------------------
|
||||
const nameToNewId = new Map<string, string>();
|
||||
let exercisesCreated = 0;
|
||||
let exercisesSkipped = 0;
|
||||
for (const ex of payload.exercises) {
|
||||
const existing = await tx.exercise.findUnique({
|
||||
where: { userId_name: { userId: user.id, name: ex.name } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
nameToNewId.set(ex.name, existing.id);
|
||||
exercisesSkipped++;
|
||||
continue;
|
||||
}
|
||||
const created = await tx.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: ex.name,
|
||||
description: ex.description ?? null,
|
||||
type: ex.type,
|
||||
muscleGroups: ex.muscleGroups,
|
||||
inputFields: ex.inputFields ?? '["sets","reps","weight"]',
|
||||
defaultWeightUnit: ex.defaultWeightUnit ?? null,
|
||||
isCustom: ex.isCustom ?? false,
|
||||
} as Prisma.ExerciseUncheckedCreateInput,
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
nameToNewId.set(created.name, created.id);
|
||||
exercisesCreated++;
|
||||
}
|
||||
|
||||
// ---- Workouts + sets ---------------------------------------
|
||||
let workoutsCreated = 0;
|
||||
let setsCreated = 0;
|
||||
let setsSkipped = 0;
|
||||
for (const w of payload.workouts) {
|
||||
const setData = [];
|
||||
for (const s of w.setLogs) {
|
||||
const exerciseName = exportExerciseIdToName.get(s.exerciseId);
|
||||
const targetId = exerciseName
|
||||
? nameToNewId.get(exerciseName)
|
||||
: undefined;
|
||||
if (!targetId) {
|
||||
setsSkipped++;
|
||||
continue;
|
||||
}
|
||||
setData.push({
|
||||
exerciseId: targetId,
|
||||
setNumber: s.setNumber,
|
||||
reps: s.reps ?? null,
|
||||
weight: s.weight ?? null,
|
||||
weightUnit: s.weightUnit ?? 'lbs',
|
||||
rpe: s.rpe ?? null,
|
||||
durationSeconds: s.durationSeconds ?? null,
|
||||
distance: s.distance ?? null,
|
||||
distanceUnit: s.distanceUnit ?? null,
|
||||
calories: s.calories ?? null,
|
||||
customMetrics: s.customMetrics ?? null,
|
||||
notes: s.notes ?? null,
|
||||
});
|
||||
}
|
||||
await tx.workout.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date: new Date(w.date),
|
||||
name: w.name ?? null,
|
||||
notes: w.notes ?? null,
|
||||
durationMinutes: w.durationMinutes ?? null,
|
||||
difficulty: w.difficulty ?? null,
|
||||
caloriesBurned: w.caloriesBurned ?? null,
|
||||
setLogs: setData.length > 0 ? { create: setData } : undefined,
|
||||
} as Prisma.WorkoutUncheckedCreateInput,
|
||||
});
|
||||
workoutsCreated++;
|
||||
setsCreated += setData.length;
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
exercisesCreated,
|
||||
exercisesSkipped,
|
||||
workoutsCreated,
|
||||
setsCreated,
|
||||
setsSkipped,
|
||||
};
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
return NextResponse.json(summary);
|
||||
} catch (err) {
|
||||
console.error('POST /api/me/import error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: (err as Error).message ?? 'Internal server error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma";
|
||||
|
||||
@@ -47,27 +48,24 @@ export async function GET(request: NextRequest) {
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
|
||||
const offset = parseInt(searchParams.get("offset") || "0");
|
||||
|
||||
const where: any = {
|
||||
const where: Prisma.WorkoutWhereInput = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
where.name = {
|
||||
contains: query,
|
||||
};
|
||||
where.name = { contains: query };
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
where.date = {};
|
||||
if (dateFrom) {
|
||||
where.date.gte = new Date(dateFrom);
|
||||
}
|
||||
const dateFilter: Prisma.DateTimeFilter = {};
|
||||
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||
if (dateTo) {
|
||||
const toDate = new Date(dateTo);
|
||||
toDate.setHours(23, 59, 59, 999);
|
||||
where.date.lte = toDate;
|
||||
dateFilter.lte = toDate;
|
||||
}
|
||||
where.date = dateFilter;
|
||||
}
|
||||
|
||||
const [workouts, total] = await Promise.all([
|
||||
@@ -134,19 +132,23 @@ export async function POST(request: NextRequest) {
|
||||
// Extract caloriesBurned — handled via raw SQL after creation
|
||||
const caloriesValue = validated.caloriesBurned;
|
||||
|
||||
const createData: any = {
|
||||
userId: user.id,
|
||||
// Note: caloriesBurned was historically handled via raw SQL because
|
||||
// older Prisma client generations didn't include the column. Schema
|
||||
// and client are now aligned, so it's a normal field — but we keep
|
||||
// the post-create raw-SQL setter call below to avoid touching the
|
||||
// existing call site in case it's relied upon elsewhere.
|
||||
const createData: Prisma.WorkoutCreateInput = {
|
||||
user: { connect: { id: user.id } },
|
||||
name: validated.name || null,
|
||||
notes: validated.notes,
|
||||
durationMinutes: validated.durationMinutes,
|
||||
difficulty: validated.difficulty,
|
||||
// caloriesBurned handled separately via raw SQL
|
||||
date: workoutDate,
|
||||
setLogs:
|
||||
validated.sets.length > 0
|
||||
? {
|
||||
create: validated.sets.map((set) => ({
|
||||
exerciseId: set.exerciseId,
|
||||
exercise: { connect: { id: set.exerciseId } },
|
||||
setNumber: set.setNumber,
|
||||
reps: set.reps,
|
||||
weight: set.weight,
|
||||
@@ -161,7 +163,7 @@ export async function POST(request: NextRequest) {
|
||||
? JSON.stringify(set.customMetrics)
|
||||
: undefined,
|
||||
notes: set.notes,
|
||||
} as any)),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ImportSummary {
|
||||
mode: 'merge' | 'replace';
|
||||
exercisesCreated: number;
|
||||
exercisesSkipped: number;
|
||||
workoutsCreated: number;
|
||||
setsCreated: number;
|
||||
setsSkipped: number;
|
||||
}
|
||||
|
||||
export default function ExportMyData() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<ImportSummary | null>(null);
|
||||
|
||||
const handleClick = async () => {
|
||||
const handleExport = async () => {
|
||||
setError(null);
|
||||
setSummary(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/me/export');
|
||||
@@ -19,7 +30,6 @@ export default function ExportMyData() {
|
||||
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';
|
||||
@@ -34,31 +44,135 @@ export default function ExportMyData() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (
|
||||
file: File,
|
||||
mode: 'merge' | 'replace',
|
||||
) => {
|
||||
setError(null);
|
||||
setSummary(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Selected file is not valid JSON.');
|
||||
}
|
||||
const body: Record<string, unknown> = { payload, mode };
|
||||
if (mode === 'replace') body.confirm = 'REPLACE';
|
||||
const res = await fetch('/api/me/import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}));
|
||||
throw new Error(errBody.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setSummary((await res.json()) as ImportSummary);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPickMerge = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleImport(f, 'merge');
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const onPickReplace = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
'REPLACE deletes every workout, set, exercise, and program currently in your account before importing the file. This is irreversible. Continue?',
|
||||
)
|
||||
) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
void handleImport(f, 'replace');
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
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
|
||||
Export & import 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.
|
||||
program tied to your account. Re-import on another instance
|
||||
(or this one after a wipe) to restore.
|
||||
</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>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
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 ? 'Working...' : 'Download JSON'}
|
||||
</button>
|
||||
|
||||
<label className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 cursor-pointer">
|
||||
Import (merge)
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={onPickMerge}
|
||||
disabled={busy}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 uppercase tracking-wider hover:bg-red-900/30 cursor-pointer">
|
||||
Import (replace)
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={onPickReplace}
|
||||
disabled={busy}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-500">
|
||||
<strong className="text-zinc-300">Merge</strong> keeps your existing
|
||||
data and adds the imported rows. Exercises with names you already
|
||||
have are skipped — your custom version wins.{' '}
|
||||
<strong className="text-zinc-300">Replace</strong> deletes everything
|
||||
in your account first.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
|
||||
Imported in <strong>{summary.mode}</strong> mode —{' '}
|
||||
{summary.exercisesCreated} exercises created
|
||||
{summary.exercisesSkipped > 0
|
||||
? `, ${summary.exercisesSkipped} skipped (already existed)`
|
||||
: ''}
|
||||
; {summary.workoutsCreated} workouts created;{' '}
|
||||
{summary.setsCreated} sets imported
|
||||
{summary.setsSkipped > 0
|
||||
? ` (${summary.setsSkipped} skipped — exercise not found)`
|
||||
: ''}
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import bcryptjs from "bcryptjs";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "./prisma";
|
||||
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||
@@ -6,21 +6,22 @@ import { cookies } from "next/headers";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Hash a password using bcryptjs
|
||||
* Hash a password using bcrypt (native).
|
||||
*
|
||||
* Switched from bcryptjs (pure-JS) for ~10x speedup on login. The hash
|
||||
* format is cross-compatible — bcryptjs-generated hashes verify cleanly
|
||||
* here, and vice versa. Salt rounds = 10 (matches the original
|
||||
* bcryptjs setting and the StartOS change-admin-credentials action).
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcryptjs.genSalt(10);
|
||||
return bcryptjs.hash(password, salt);
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against its hash
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hash: string
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
return bcryptjs.compare(password, hash);
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "../prisma";
|
||||
import { Workout } from "@prisma/client";
|
||||
import { Prisma, Workout } from "@prisma/client";
|
||||
import { SearchFilters } from "@/types";
|
||||
|
||||
/**
|
||||
@@ -18,21 +18,20 @@ export async function getWorkouts(
|
||||
offset = 0,
|
||||
} = filters || {};
|
||||
|
||||
const where: any = {
|
||||
const where: Prisma.WorkoutWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
where.name = {
|
||||
contains: query,
|
||||
};
|
||||
where.name = { contains: query };
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
where.date = {};
|
||||
if (dateFrom) where.date.gte = dateFrom;
|
||||
if (dateTo) where.date.lte = dateTo;
|
||||
const dateFilter: Prisma.DateTimeFilter = {};
|
||||
if (dateFrom) dateFilter.gte = dateFrom;
|
||||
if (dateTo) dateFilter.lte = dateTo;
|
||||
where.date = dateFilter;
|
||||
}
|
||||
|
||||
const workouts = await prisma.workout.findMany({
|
||||
|
||||
+64
-22
@@ -1,34 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Per-request CSP nonce + auth gating.
|
||||
*
|
||||
* Nonces drop the previous `'unsafe-inline'` from `script-src`. Next
|
||||
* 13.4+ automatically picks up the nonce from the `x-nonce` request
|
||||
* header and stamps it on the bootstrap inline scripts it emits, so
|
||||
* the in-app code (which doesn't itself emit inline `<script>`) Just
|
||||
* Works without any layout changes.
|
||||
*
|
||||
* `style-src` keeps `'unsafe-inline'` because Tailwind / Next still
|
||||
* inject critical inline `<style>` blocks. Tightening that requires
|
||||
* either nonce-stamping styles too (Next doesn't do this automatically)
|
||||
* or hashing the inline style bodies, both of which are bigger lifts.
|
||||
*
|
||||
* The CSP set here REPLACES the static CSP previously in
|
||||
* next.config.js (a header set by middleware overrides one set in the
|
||||
* static config for the same key). All other static security headers
|
||||
* (HSTS, Referrer-Policy, etc.) stay in next.config.js.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const sessionToken = request.cookies.get('sessionToken')?.value;
|
||||
|
||||
// Get session token from cookies
|
||||
const sessionToken = request.cookies.get("sessionToken")?.value;
|
||||
|
||||
// Protect /main/* routes — redirect to login if no cookie
|
||||
if (pathname.startsWith("/main")) {
|
||||
if (!sessionToken) {
|
||||
return NextResponse.redirect(new URL("/auth/login", request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
// ---- Auth gating (existing behavior) -----------------------------
|
||||
if (pathname.startsWith('/main') && !sessionToken) {
|
||||
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||
}
|
||||
if (
|
||||
pathname.startsWith('/api') &&
|
||||
!pathname.startsWith('/api/auth') &&
|
||||
!pathname.startsWith('/api/health') &&
|
||||
!sessionToken
|
||||
) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Protect /api/* routes (except /api/auth and /api/health)
|
||||
if (pathname.startsWith("/api")) {
|
||||
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/health")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
// ---- Per-request nonce + CSP -------------------------------------
|
||||
// 16 random bytes -> 22-char base64; plenty for a CSP nonce.
|
||||
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
|
||||
'base64',
|
||||
);
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
return NextResponse.next();
|
||||
// Forward the nonce to Next via a request header — its built-in
|
||||
// <Script> + bootstrap script emitter looks for `x-nonce` and stamps
|
||||
// it onto every inline script it generates.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
response.headers.set('Content-Security-Policy', csp);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/main/:path*", "/api/:path*"],
|
||||
matcher: [
|
||||
// Exclude static assets so the nonce response-header overhead
|
||||
// doesn't hit every image / JS chunk request. App-route requests
|
||||
// and API requests pass through.
|
||||
'/((?!_next/static|_next/image|favicon.ico|icons/|manifest.json|sw.js|sw-register.js).*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// Content-Security-Policy.
|
||||
//
|
||||
// `script-src` and `style-src` keep `'unsafe-inline'` because Next.js
|
||||
// emits inline bootstrap scripts and Tailwind's runtime CSS-in-JS path
|
||||
// requires inline styles. Tightening to nonce-based CSP is a follow-up
|
||||
// (requires switching to Next's `headers()` middleware-style nonce
|
||||
// injection, not the static config). The directives we DO get for free
|
||||
// here still cut off the most common XSS-followup patterns:
|
||||
// - frame-ancestors 'none' -> can't be embedded anywhere (clickjacking)
|
||||
// - base-uri 'self' -> attacker can't pivot relative URLs
|
||||
// - form-action 'self' -> stolen forms can't POST credentials away
|
||||
// - object-src 'none' -> no Flash/Java applets, full stop
|
||||
// - default-src 'self' -> images/fetches/etc default to same-origin
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
// Content-Security-Policy is set per-request in middleware.ts so it
|
||||
// can include a per-request nonce (drops the previous 'unsafe-inline'
|
||||
// from script-src). Other security headers stay here as static
|
||||
// response headers.
|
||||
const securityHeaders = [
|
||||
{ key: 'Content-Security-Policy', value: csp },
|
||||
// HSTS: tell browsers to use HTTPS only for this origin for a year.
|
||||
// StartOS terminates TLS in front of the container, so this applies
|
||||
// to the public hostname users actually visit.
|
||||
|
||||
Generated
+43
-12
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.0.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^20.5.0",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
@@ -1366,12 +1366,15 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
@@ -2560,11 +2563,19 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
@@ -5625,6 +5636,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||
@@ -5654,6 +5674,17 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.0.0",
|
||||
@@ -32,7 +32,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^20.5.0",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as bcryptjs from "bcryptjs";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
@@ -44,7 +44,7 @@ function loadLibrary(): LibraryExercise[] {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const hashedPassword = await bcryptjs.hash("workout123", 10);
|
||||
const hashedPassword = await bcrypt.hash("workout123", 10);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: "admin@local" },
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
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 };
|
||||
});
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { POST as saveImport } from '@/app/api/workouts/import/save/route';
|
||||
|
||||
function jsonReq(body: unknown): Request {
|
||||
return new Request('http://x/api/workouts/import/save', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function makeUser(opts: { email: string }) {
|
||||
return prisma.user.create({
|
||||
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.session.deleteMany();
|
||||
await prisma.exercise.deleteMany();
|
||||
await prisma.workout.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.instanceSettings.deleteMany();
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
describe('POST /api/workouts/import/save', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
const res = await saveImport(
|
||||
jsonReq({ workouts: [{ date: '2026-01-01', exercises: [] }] }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 on empty workouts array (Zod min(1))', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await saveImport(jsonReq({ workouts: [] }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('matches existing exercise by case-insensitive name', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
const bench = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
name: 'Bench Press',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
|
||||
const res = await saveImport(
|
||||
jsonReq({
|
||||
workouts: [
|
||||
{
|
||||
date: '2026-01-15',
|
||||
exercises: [
|
||||
{
|
||||
name: 'bench press', // lowercased — should still match
|
||||
sets: [{ reps: 5, weight: 225, weightUnit: 'lbs' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Should NOT create a duplicate exercise.
|
||||
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
|
||||
|
||||
const sets = await prisma.setLog.findMany({
|
||||
where: { exerciseId: bench.id },
|
||||
});
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(sets[0].weight).toBe(225);
|
||||
});
|
||||
|
||||
it('creates new exercise (isCustom=true, type defaults to "other") when no match', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
|
||||
const res = await saveImport(
|
||||
jsonReq({
|
||||
workouts: [
|
||||
{
|
||||
date: '2026-01-15',
|
||||
exercises: [
|
||||
{
|
||||
name: 'Brand New Exercise',
|
||||
sets: [{ reps: 10 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const ex = await prisma.exercise.findUnique({
|
||||
where: {
|
||||
userId_name: { userId: u.id, name: 'Brand New Exercise' },
|
||||
},
|
||||
});
|
||||
expect(ex).toBeTruthy();
|
||||
expect(ex?.isCustom).toBe(true);
|
||||
expect(ex?.type).toBe('other');
|
||||
});
|
||||
|
||||
it('honors explicit existingExerciseId over name lookup', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
const a = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
name: 'Squat',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
const b = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
name: 'Different Squat Variation',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
|
||||
// Even though the name matches `a`, existingExerciseId says use `b`.
|
||||
await saveImport(
|
||||
jsonReq({
|
||||
workouts: [
|
||||
{
|
||||
date: '2026-01-15',
|
||||
exercises: [
|
||||
{
|
||||
name: 'Squat',
|
||||
existingExerciseId: b.id,
|
||||
sets: [{ reps: 5, weight: 315 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const sets = await prisma.setLog.findMany({
|
||||
where: { exerciseId: b.id },
|
||||
});
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(
|
||||
await prisma.setLog.count({ where: { exerciseId: a.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('creates multiple workouts in one call and returns the ids', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
|
||||
const res = await saveImport(
|
||||
jsonReq({
|
||||
workouts: [
|
||||
{
|
||||
date: '2026-01-01',
|
||||
exercises: [{ name: 'Squat', sets: [{ reps: 5 }] }],
|
||||
},
|
||||
{
|
||||
date: '2026-01-03',
|
||||
exercises: [{ name: 'Bench', sets: [{ reps: 5 }] }],
|
||||
},
|
||||
{
|
||||
date: '2026-01-05',
|
||||
exercises: [{ name: 'Deadlift', sets: [{ reps: 5 }] }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.count).toBe(3);
|
||||
expect(body.created).toHaveLength(3);
|
||||
|
||||
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(3);
|
||||
});
|
||||
|
||||
it('numbers sets sequentially per exercise per workout', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
|
||||
await saveImport(
|
||||
jsonReq({
|
||||
workouts: [
|
||||
{
|
||||
date: '2026-01-15',
|
||||
exercises: [
|
||||
{
|
||||
name: 'Squat',
|
||||
sets: [
|
||||
{ reps: 5, weight: 135 },
|
||||
{ reps: 5, weight: 185 },
|
||||
{ reps: 5, weight: 225 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const sets = await prisma.setLog.findMany({
|
||||
orderBy: { setNumber: 'asc' },
|
||||
});
|
||||
expect(sets.map((s) => s.setNumber)).toEqual([1, 2, 3]);
|
||||
expect(sets.map((s) => s.weight)).toEqual([135, 185, 225]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
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 };
|
||||
});
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { POST as importPost } from '@/app/api/me/import/route';
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest('http://x/api/me/import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUser(opts: { email: string }) {
|
||||
return prisma.user.create({
|
||||
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.session.deleteMany();
|
||||
await prisma.exercise.deleteMany();
|
||||
await prisma.workout.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.instanceSettings.deleteMany();
|
||||
getCurrentUserMock.mockReset();
|
||||
});
|
||||
|
||||
const sampleExport = (overrides?: Partial<{ exercises: unknown[]; workouts: unknown[] }>) => ({
|
||||
schema: 'proof-of-work-export@1',
|
||||
exercises: overrides?.exercises ?? [
|
||||
{
|
||||
id: 'cE1',
|
||||
name: 'Imported Bench',
|
||||
type: 'barbell',
|
||||
muscleGroups: '["chest"]',
|
||||
inputFields: '["sets","reps","weight"]',
|
||||
defaultWeightUnit: null,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'cE2',
|
||||
name: 'Imported Squat',
|
||||
type: 'barbell',
|
||||
muscleGroups: '["legs"]',
|
||||
inputFields: '["sets","reps","weight"]',
|
||||
defaultWeightUnit: null,
|
||||
isCustom: false,
|
||||
},
|
||||
],
|
||||
workouts: overrides?.workouts ?? [
|
||||
{
|
||||
date: '2026-04-01T12:00:00.000Z',
|
||||
name: 'Day 1',
|
||||
setLogs: [
|
||||
{ exerciseId: 'cE1', setNumber: 1, reps: 5, weight: 225 },
|
||||
{ exerciseId: 'cE1', setNumber: 2, reps: 5, weight: 245 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2026-04-03T12:00:00.000Z',
|
||||
name: 'Day 2',
|
||||
setLogs: [
|
||||
{ exerciseId: 'cE2', setNumber: 1, reps: 5, weight: 315 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('POST /api/me/import', () => {
|
||||
it('returns 401 unauthenticated', async () => {
|
||||
getCurrentUserMock.mockResolvedValue(null);
|
||||
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects unknown schema versions', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await importPost(
|
||||
jsonReq({
|
||||
payload: { schema: 'something-else@99', exercises: [], workouts: [] },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('merge mode imports exercises and workouts attributed to the actor', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.exercisesCreated).toBe(2);
|
||||
expect(body.workoutsCreated).toBe(2);
|
||||
expect(body.setsCreated).toBe(3);
|
||||
|
||||
const exercises = await prisma.exercise.findMany({
|
||||
where: { userId: u.id },
|
||||
});
|
||||
expect(exercises.map((e) => e.name).sort()).toEqual([
|
||||
'Imported Bench',
|
||||
'Imported Squat',
|
||||
]);
|
||||
const workouts = await prisma.workout.findMany({
|
||||
where: { userId: u.id },
|
||||
include: { setLogs: true },
|
||||
});
|
||||
expect(workouts).toHaveLength(2);
|
||||
const totalSets = workouts.reduce((n, w) => n + w.setLogs.length, 0);
|
||||
expect(totalSets).toBe(3);
|
||||
});
|
||||
|
||||
it('merge mode skips exercises whose name already exists for the user', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
await prisma.exercise.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
name: 'Imported Bench',
|
||||
type: 'barbell',
|
||||
muscleGroups: '["chest"]',
|
||||
isCustom: true, // user's own custom version
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
||||
const body = await res.json();
|
||||
expect(body.exercisesCreated).toBe(1); // only Squat
|
||||
expect(body.exercisesSkipped).toBe(1); // Bench already existed
|
||||
|
||||
// The user's custom row was NOT overwritten.
|
||||
const bench = await prisma.exercise.findUnique({
|
||||
where: { userId_name: { userId: u.id, name: 'Imported Bench' } },
|
||||
});
|
||||
expect(bench?.isCustom).toBe(true);
|
||||
|
||||
// Workouts were still created and bound to the user's existing
|
||||
// (custom) Bench, not the imported one.
|
||||
const sets = await prisma.setLog.findMany({
|
||||
where: { exerciseId: bench!.id },
|
||||
});
|
||||
expect(sets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('replace mode requires explicit confirmation', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await importPost(
|
||||
jsonReq({ payload: sampleExport(), mode: 'replace' }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/REPLACE/);
|
||||
});
|
||||
|
||||
it('replace mode wipes existing user-owned data first', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
const existing = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
name: 'Old Custom',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
await prisma.workout.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
date: new Date('2026-01-01'),
|
||||
setLogs: { create: [{ exerciseId: existing.id, setNumber: 1, reps: 1 }] },
|
||||
},
|
||||
});
|
||||
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(1);
|
||||
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
|
||||
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await importPost(
|
||||
jsonReq({
|
||||
payload: sampleExport(),
|
||||
mode: 'replace',
|
||||
confirm: 'REPLACE',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.mode).toBe('replace');
|
||||
|
||||
// Old custom is gone; new imported data is in place.
|
||||
expect(
|
||||
await prisma.exercise.findUnique({
|
||||
where: { userId_name: { userId: u.id, name: 'Old Custom' } },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(2);
|
||||
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps imports scoped to the actor, never to other users', async () => {
|
||||
const me = await makeUser({ email: 'me@x' });
|
||||
const other = await makeUser({ email: 'other@x' });
|
||||
await prisma.exercise.create({
|
||||
data: {
|
||||
userId: other.id,
|
||||
name: 'Other User Exercise',
|
||||
type: 'barbell',
|
||||
muscleGroups: '[]',
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(me);
|
||||
await importPost(jsonReq({ payload: sampleExport() }));
|
||||
|
||||
// The other user's data is completely untouched.
|
||||
expect(await prisma.exercise.count({ where: { userId: other.id } })).toBe(1);
|
||||
expect(await prisma.workout.count({ where: { userId: other.id } })).toBe(0);
|
||||
expect(
|
||||
(await prisma.exercise.findFirst({ where: { userId: other.id } }))?.name,
|
||||
).toBe('Other User Exercise');
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -25,7 +25,11 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
# openssl: Prisma engine runtime
|
||||
# python3 + make + g++: native node-gyp builds (bcrypt). Even when the
|
||||
# `bcrypt` npm package ships musl prebuilts, a postinstall fallback
|
||||
# compile is the safety net — no compile, no boot.
|
||||
RUN apk add --no-cache openssl python3 make g++
|
||||
|
||||
COPY proof-of-work/package.json proof-of-work/package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -66,6 +70,11 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
|
||||
# Native bcrypt binding. Next.js standalone tracing usually picks up
|
||||
# the .node file but we copy explicitly as a belt-and-braces guard —
|
||||
# bundling failures here surface as auth being silently broken at boot.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcrypt ./node_modules/bcrypt
|
||||
|
||||
# Empty-schema fallback DB (used only when no baked seed is available on a
|
||||
# brand-new sideload).
|
||||
COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db
|
||||
|
||||
Reference in New Issue
Block a user