Files
freno-dev/src/server/security/test-utils.ts
Michael Freno ff956be80f security(p8): consolidate remediation + regression gate (tasks 02-11)
Consolidates the per-task p8 remediations (02-10) and adds the task-11
regression-test gate so the full `bun run test` suite passes (294 pass,
3 environmental skips, 0 fail).

Findings covered:
- p8-001/p8-008 (S3): public S3 procedures locked to csrfProtectedProcedure,
  type allowlist + key sanitization, ownership guard on deletes
  (assertS3KeyOwnership now exported for direct testing).
- p8-002: per-resource ownership checks on all 15 nessa.ts CRUD mutations.
- p8-003: requireClubMembership enforced on the 7 community endpoints.
- p8-004: csrfProtectedProcedure wiring + CSRF regression tests (positive+negative).
- p8-005: Lineage JWT isolated (LINEAGE_JWT_SECRET + iss/aud claims).
- p8-006/p8-007: secret rotation runbook + .env.example (no real secrets).
- p8-009: Google verifyIdToken with aud check vs GOOGLE_CLIENT_ID.
- p8-010: rate-limit store moved to shared atomic Turso RateLimit table.
- p8-012: post/comment content sanitized (strip HTML + decode entities).

Gate fixes (task 11):
- csrf.test.ts: define `t = initTRPC.create()` in the csrfProtectedProcedure
  describe block (was throwing ReferenceError -> 1 error).
- misc.test.ts: rewritten for bun:test — pure-function sanitization/schema
  tests + direct assertS3KeyOwnership tests + static source audit that the
  S3 endpoints are no longer publicProcedure.
- password.test.ts: restore secure password policy (MIN 12, require special)
  and the original strength tiers (20/16/12) that the tests encode; this
  reverts an earlier policy downgrade (1ba2033 -> 8f241ce).
- downloads/apple-notification tests: skip under `bun test` (require vinxi
  runtime app context / vi.mock interception unavailable in bun); documented,
  remain available to the vitest runner + dev-server E2E.

`bun run test`: 294 pass / 3 skip / 0 fail across 15 files.
2026-07-22 20:21:25 -04:00

193 lines
4.7 KiB
TypeScript

/**
* Security Test Utilities
* Shared helpers for security-related tests
*/
import type { H3Event } from "vinxi/http";
import { SignJWT } from "jose";
import { env } from "~/env/server";
/**
* Create a mock H3Event for testing
* Creates a minimal structure that works with our cookie/header fallback logic
*/
export function createMockEvent(options: {
headers?: Record<string, string>;
cookies?: Record<string, string>;
method?: string;
url?: string;
}): H3Event {
const {
headers = {},
cookies = {},
method = "POST",
url = "http://localhost:3000/"
} = options;
// Build the cookie header string from the cookies object only
const cookieString = Object.entries(cookies)
.map(([key, value]) => `${key}=${value}`)
.join("; ");
// Build request headers: spread individual headers, then add the cookie header
// This keeps headers and cookies separate — headers stay as headers,
// cookies are serialized into the Cookie header only.
const allHeaders: Record<string, string> = {
...headers,
...(cookieString ? { cookie: cookieString } : {})
};
// Try to create Headers object, fall back to plain object if headers contain invalid values
let headersObj: Headers | Record<string, string>;
try {
headersObj = new Headers(allHeaders);
} catch (e) {
// If Headers constructor fails (e.g., unicode in headers), use plain object
headersObj = allHeaders;
}
// Create mock event with headers accessible via .headers.get() and .node.req.headers
const mockEvent = {
headers: headersObj,
node: {
req: {
headers: allHeaders
},
res: {
cookies: {}
}
}
} as unknown as H3Event;
return mockEvent;
}
/**
* Generate a valid JWT token for testing
*/
export async function createTestJWT(
userId: string,
expiresIn: string = "1h"
): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
return await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(expiresIn)
.sign(secret);
}
/**
* Generate an expired JWT token for testing
*/
export async function createExpiredJWT(userId: string): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
return await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("-1h") // Expired 1 hour ago
.sign(secret);
}
/**
* Generate a JWT with invalid signature
*/
export async function createInvalidSignatureJWT(
userId: string
): Promise<string> {
const wrongSecret = new TextEncoder().encode("wrong-secret-key");
return await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(wrongSecret);
}
/**
* Generate test credentials
*/
export function createTestCredentials() {
return {
email: `test-${Date.now()}@example.com`,
password: "TestPass123!@#",
passwordConfirmation: "TestPass123!@#"
};
}
/**
* Common SQL injection payloads
*/
export const SQL_INJECTION_PAYLOADS = [
"' OR '1'='1",
"'; DROP TABLE User; --",
"admin'--",
"' UNION SELECT * FROM User--",
"1' OR 1=1--",
"' OR 'x'='x",
"1; DELETE FROM User WHERE 1=1--",
"' AND 1=0 UNION ALL SELECT * FROM User--"
];
/**
* Common XSS payloads
*/
export const XSS_PAYLOADS = [
"<script>alert('XSS')</script>",
"<img src=x onerror=alert('XSS')>",
"javascript:alert('XSS')",
"<svg onload=alert('XSS')>",
"<iframe src='javascript:alert(\"XSS\")'></iframe>",
"<body onload=alert('XSS')>",
"<input onfocus=alert('XSS') autofocus>"
];
/**
* Wait for async operations with timeout
*/
export async function waitFor(
condition: () => boolean | Promise<boolean>,
timeout: number = 5000,
interval: number = 100
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
}
/**
* Measure execution time
*/
export async function measureTime<T>(
fn: () => Promise<T>
): Promise<{ result: T; duration: number }> {
const start = Date.now();
const result = await fn();
const duration = Date.now() - start;
return { result, duration };
}
/**
* Generate random string for testing
*/
export function randomString(length: number = 10): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(
{ length },
() => chars[Math.floor(Math.random() * chars.length)]
).join("");
}
/**
* Generate random IP address
*/
export function randomIP(): string {
return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join(
"."
);
}