v1.1.0:9 — P2 hardening: input-validation 400s, auth rate-limit, XFF anti-spoof, non-root container
P2 batch from the 2026-06-13 full-eval (EVALUATION.md / ROADMAP.md), reviewed by the reviewer agent. App-code + packaging only; no schema or data change, existing /data untouched. Input validation: malformed JSON bodies, invalid date, and out-of-range or non-numeric pagination on /api/workouts now return 400 instead of 500. New lib/http.ts readJsonBody maps a bad body to a ZodError across the 11 CRUD routes whose catch maps ZodError to 400; me/import and admin/signups guard request.json() in an explicit try/catch. Rate limiting: POST /api/auth now shares the UI login server action's per-IP 10-per-15min cap and returns 429 + Retry-After. clientIpFromHeaders reads the rightmost (trusted-proxy-appended) X-Forwarded-For entry instead of the spoofable leftmost. Container: drops root. The entrypoint prepares /data as root, chowns it to nextjs, then exec su-exec nextjs:nodejs node server.js (su-exec added to the runner image). The container drop needs live sideload verification.
This commit is contained in:
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { verifyPassword, createSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { readJsonBody } from '@/lib/http';
|
||||
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -10,7 +12,20 @@ const loginSchema = z.object({
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Per-IP cap, sharing the `login:${ip}` bucket with the UI login
|
||||
// server action (app/auth/login/actions.ts): 10 attempts / 15 min.
|
||||
// Without this the raw API endpoint is an uncapped credential-stuffing
|
||||
// surface that bypasses the server-action's limiter.
|
||||
const ip = clientIpFromHeaders(request.headers);
|
||||
const limited = rateLimit(`login:${ip}`, { limit: 10, windowMs: 15 * 60_000 });
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Too many login attempts. Try again in ${limited.retryAfterSec}s.` },
|
||||
{ status: 429, headers: { 'Retry-After': String(limited.retryAfterSec) } }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const { email, password } = loginSchema.parse(body);
|
||||
|
||||
// Look up user by email
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -118,7 +119,7 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = updateExerciseSchema.parse(body);
|
||||
|
||||
const data: any = {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getExercises, createExercise } from "@/lib/db/exercises";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -60,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = CreateExerciseSchema.parse(body);
|
||||
|
||||
const existing = await prisma.exercise.findUnique({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
|
||||
const SeedExerciseSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const parsed = SeedPayloadSchema.parse(body);
|
||||
|
||||
const existingExercises = await prisma.exercise.findMany({
|
||||
|
||||
@@ -92,7 +92,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
// This route uses safeParse (not an `instanceof z.ZodError` catch), so a
|
||||
// malformed body would otherwise reach the generic catch as a 500. Guard
|
||||
// it explicitly — matches the pattern in app/api/admin/signups/route.ts.
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
const parsed = requestBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -61,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = PreferencesSchema.parse(body);
|
||||
|
||||
let preferences = await prisma.userPreferences.findUnique({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { getProgramById } from "@/lib/db/programs";
|
||||
|
||||
/**
|
||||
@@ -80,7 +81,7 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: "Program not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = patchSchema.parse(body);
|
||||
|
||||
// If replacing the tree, verify exercise ownership.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { getPrograms } from "@/lib/db/programs";
|
||||
|
||||
/**
|
||||
@@ -61,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = createProgramSchema.parse(body);
|
||||
|
||||
// Verify any referenced exerciseIds belong to this user.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { z } from "zod";
|
||||
|
||||
// GET: Get workout by ID
|
||||
@@ -95,7 +96,7 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = updateWorkoutSchema.parse(body);
|
||||
|
||||
const workoutData: Record<string, unknown> = {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
import { z } from "zod";
|
||||
|
||||
const addSetsSchema = z.object({
|
||||
@@ -46,7 +47,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = addSetsSchema.parse(body);
|
||||
|
||||
// Delete existing sets for this exercise in this workout (replace mode)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
|
||||
const setSchema = z.object({
|
||||
reps: z.number().int().positive().optional(),
|
||||
@@ -40,7 +41,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = saveImportSchema.parse(body);
|
||||
|
||||
// Load all user exercises for matching
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readJsonBody } from "@/lib/http";
|
||||
|
||||
// Schema now supports creating empty workouts (just date) or with sets
|
||||
const createWorkoutSchema = z.object({
|
||||
@@ -11,7 +12,10 @@ const createWorkoutSchema = z.object({
|
||||
durationMinutes: z.number().int().positive().optional(),
|
||||
difficulty: z.number().int().min(1).max(10).optional(),
|
||||
caloriesBurned: z.number().int().positive().optional(),
|
||||
date: z.string().optional(), // ISO date string or date-only string
|
||||
date: z
|
||||
.string()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" })
|
||||
.optional(), // ISO date string or date-only string
|
||||
sets: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -45,8 +49,25 @@ export async function GET(request: NextRequest) {
|
||||
const query = searchParams.get("q");
|
||||
const dateFrom = searchParams.get("dateFrom");
|
||||
const dateTo = searchParams.get("dateTo");
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
|
||||
const offset = parseInt(searchParams.get("offset") || "0");
|
||||
// Validate pagination up front: a negative offset or non-numeric value
|
||||
// would otherwise reach Prisma's `skip`/`take` and throw a generic 500.
|
||||
const pagination = z
|
||||
.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
.safeParse({
|
||||
limit: searchParams.get("limit") || undefined,
|
||||
offset: searchParams.get("offset") || undefined,
|
||||
});
|
||||
|
||||
if (!pagination.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid pagination parameters", details: pagination.error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { limit, offset } = pagination.data;
|
||||
|
||||
const where: Prisma.WorkoutWhereInput = {
|
||||
userId: user.id,
|
||||
@@ -116,7 +137,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = await readJsonBody(request);
|
||||
const validated = createWorkoutSchema.parse(body);
|
||||
|
||||
const workoutDate = validated.date ? new Date(validated.date) : new Date();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Parse a JSON request body, turning a malformed or empty body into a
|
||||
* `ZodError` rather than letting the raw `SyntaxError` from `request.json()`
|
||||
* fall through a route's generic `catch` and become an HTTP 500.
|
||||
*
|
||||
* Why a `ZodError` specifically: every body-parsing route already maps
|
||||
* `instanceof z.ZodError` to a 400. Throwing one here means a malformed body
|
||||
* returns 400 across all of them with no per-route catch changes — the call
|
||||
* site swaps `request.json()` for `readJsonBody(request)` and nothing else
|
||||
* moves. (It is a genuine `z.ZodError`, so the `instanceof` checks hold.)
|
||||
*/
|
||||
export async function readJsonBody(request: Request): Promise<unknown> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch {
|
||||
throw new z.ZodError([
|
||||
{
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [],
|
||||
message: "Request body must be valid JSON",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -44,19 +44,37 @@ export function rateLimit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client IP extraction. In a StartOS deployment the Next.js
|
||||
* server sits behind a single proxy hop, so the leftmost
|
||||
* `x-forwarded-for` entry is the originating client. If headers are
|
||||
* absent (direct access in dev), fall back to the literal "unknown" key
|
||||
* so the limiter still applies as a global rate cap.
|
||||
* Best-effort client IP extraction for rate-limit keys.
|
||||
*
|
||||
* `X-Forwarded-For` is a client-appendable, comma-separated list: each proxy
|
||||
* APPENDS the address it observed. A direct client can therefore forge any
|
||||
* number of leftmost entries — using `xff.split(',')[0]` (the leftmost) lets
|
||||
* an attacker rotate a fake IP per request and defeat the limiter entirely.
|
||||
*
|
||||
* In a StartOS deployment the Next.js server sits behind exactly one trusted
|
||||
* proxy hop, so the RIGHTMOST entry is the address that proxy actually saw —
|
||||
* the only value the client cannot spoof. We key off that. (If the proxy
|
||||
* overwrites rather than appends XFF, the list has a single entry and
|
||||
* rightmost == leftmost, so this is also correct in that case.) If XFF is
|
||||
* absent (direct access in dev), fall back to `x-real-ip`, then to the
|
||||
* literal "unknown" key so the limiter still applies as a global cap.
|
||||
*
|
||||
* Assumes a single trusted hop; if the deployment ever grows additional
|
||||
* trusted proxies, count that many entries in from the right instead.
|
||||
*/
|
||||
export function clientIpFromHeaders(headers: Headers): string {
|
||||
const xff = headers.get('x-forwarded-for');
|
||||
if (xff) {
|
||||
const first = xff.split(',')[0]?.trim();
|
||||
if (first) return first;
|
||||
const parts = xff
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) return parts[parts.length - 1];
|
||||
}
|
||||
const real = headers.get('x-real-ip');
|
||||
if (real) return real;
|
||||
if (real) {
|
||||
const trimmed = real.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -40,9 +40,21 @@ describe('rateLimit', () => {
|
||||
});
|
||||
|
||||
describe('clientIpFromHeaders', () => {
|
||||
it('uses the leftmost x-forwarded-for entry', () => {
|
||||
it('uses the rightmost (single trusted proxy hop) x-forwarded-for entry', () => {
|
||||
const h = new Headers({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
|
||||
expect(clientIpFromHeaders(h)).toBe('1.2.3.4');
|
||||
expect(clientIpFromHeaders(h)).toBe('5.6.7.8');
|
||||
});
|
||||
|
||||
it('ignores a client-spoofed leftmost entry', () => {
|
||||
// Attacker sends `X-Forwarded-For: evil`; the trusted proxy appends the
|
||||
// real client address, which must win.
|
||||
const h = new Headers({ 'x-forwarded-for': 'evil.spoofed, 203.0.113.9' });
|
||||
expect(clientIpFromHeaders(h)).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('handles a single-entry x-forwarded-for', () => {
|
||||
const h = new Headers({ 'x-forwarded-for': '203.0.113.9' });
|
||||
expect(clientIpFromHeaders(h)).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('falls back to x-real-ip', () => {
|
||||
|
||||
@@ -21,6 +21,14 @@ function jsonReq(body: unknown): NextRequest {
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
function rawReq(rawBody: string): NextRequest {
|
||||
return new NextRequest('http://x/api/me/import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: rawBody,
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUser(opts: { email: string }) {
|
||||
return prisma.user.create({
|
||||
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||
@@ -95,6 +103,13 @@ describe('POST /api/me/import', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 (not 500) on a malformed JSON body', async () => {
|
||||
const u = await makeUser({ email: 'a@x' });
|
||||
getCurrentUserMock.mockResolvedValue(u);
|
||||
const res = await importPost(rawReq('{ not valid json'));
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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 postWorkout, GET as getWorkouts } from '@/app/api/workouts/route';
|
||||
import { POST as postAuth } from '@/app/api/auth/route';
|
||||
|
||||
function jsonReq(url: string, body?: unknown, init?: RequestInit): NextRequest {
|
||||
const opts: RequestInit = {
|
||||
method: body !== undefined ? 'POST' : 'GET',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init,
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return new NextRequest(url, opts as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
/** A request carrying a raw (possibly malformed) string body. */
|
||||
function rawReq(url: string, rawBody: string, init?: RequestInit): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: rawBody,
|
||||
...init,
|
||||
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||
}
|
||||
|
||||
async function makeUser(email: string) {
|
||||
return prisma.user.create({
|
||||
data: { 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('malformed request bodies → 400 (not 500)', () => {
|
||||
it('POST /api/workouts returns 400 on a body that is not valid JSON', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await postWorkout(rawReq('http://x/api/workouts', '{ not valid json'));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/workouts returns 400 on an empty body', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await postWorkout(rawReq('http://x/api/workouts', ''));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/workouts date validation', () => {
|
||||
it('returns 400 on an unparseable date string', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await postWorkout(
|
||||
jsonReq('http://x/api/workouts', { name: 'X', date: 'not-a-date' }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a valid date-only string', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await postWorkout(
|
||||
jsonReq('http://x/api/workouts', { name: 'X', date: '2026-06-01' }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/workouts pagination validation', () => {
|
||||
it('returns 400 on a negative offset (was a Prisma 500)', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await getWorkouts(jsonReq('http://x/api/workouts?offset=-5'));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 on a non-numeric limit', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await getWorkouts(jsonReq('http://x/api/workouts?limit=abc'));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 on limit=0 (new min-1 contract)', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await getWorkouts(jsonReq('http://x/api/workouts?limit=0'));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('still serves valid pagination parameters', async () => {
|
||||
const alice = await makeUser('a@x');
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await getWorkouts(jsonReq('http://x/api/workouts?limit=10&offset=0'));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth rate limiting', () => {
|
||||
it('returns 429 + Retry-After after 10 attempts from one IP', async () => {
|
||||
// Unique key per run so the process-global limiter bucket is clean.
|
||||
const ip = `validation-test-${Math.random()}`;
|
||||
const attempt = () =>
|
||||
postAuth(
|
||||
new NextRequest('http://x/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
|
||||
body: JSON.stringify({ email: 'nobody@example.com', password: 'x' }),
|
||||
} as ConstructorParameters<typeof NextRequest>[1]),
|
||||
);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await attempt();
|
||||
expect(res.status).toBe(401); // no such user
|
||||
}
|
||||
const blocked = await attempt();
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.headers.get('retry-after')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user