mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Three defects from #645, all of which let the server report something that was not true. Tool access was enforced per route, so it drifted. createToolRoute calls requireToolAccess and the factory tools were fine, but all 45 hand-written routes had to remember the same call and none of them did. A role without tools:use could run image-to-pdf, svg-to-raster, erase-object, favicon, qr-generate, upscale, sign-pdf and the rest. The issue described this as affecting two routes; it was every one of them. The check now lives in a single preHandler keyed off the tool the router matched, so it covers sub-paths (/batch, /info, /preview, /analyze, /inspect) and any route added later without that route opting in. Ids no tool claims stay unresolved, which keeps an unknown or misfiled tool a 404 rather than telling an unauthorized caller which ids exist. Resolution reads request.routeOptions.url, the pattern the app itself registered, rather than parsing request.url a second time. find-my-way decodes before matching, so an independent parse disagrees with the router and the router wins: `/api/v1/tools/image/%66avicon` ran favicon while the gate saw no tool at all. Absolute-form request targets slipped it the same way. Taking the router's own answer removes the disagreement. A ZIP stream that failed after the 200 headers were out called reply.raw.end(). On a chunked response that is indistinguishable from success, so a client kept an archive with no central directory believing it whole. Worse, a source stream that errored had no listener: the request hung until it timed out and the error surfaced as unhandled. A poisoned-storage probe reproduced both. The socket is destroyed instead, and every source stream is listened to. svg-to-raster additionally ran its append loop past the hijack with no try/catch, where a throw leaves Fastify logging and walking away with the socket neither ended nor destroyed. pdf-to-image is fixed alongside the other two: it shipped in #643 with the destroy half but not the listener, so it hung the same way. A zero-byte upload was dropped during parsing. The client pairs results with its own file list by index, so every later result shifted onto the wrong file: one document's output was presented as another's, under another's name, while the file that actually converted was marked "not found in batch results". Empty parts now keep their slot and fail in place with a reason. Two tests in adversarial-extended.test.ts asserted the old zero-byte behavior, including a comment that batch "silently skips zero-byte parts". They now pin the replacement: still rejected, nothing processed, but the caller is told which files were empty instead of being told it sent none. A guard walks the whole catalog and fails if any of the 241 tools answers anything but 403 for a role without tools:use, so a tool cannot escape the gate by being registered in a shape nobody thought to sample. Fixes #645
457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
/**
|
|
* Test server helper -- builds a real Fastify app with an isolated Postgres
|
|
* database for integration tests.
|
|
*
|
|
* Environment variables (DATABASE_URL, WORKSPACE_PATH) are set per-fork in
|
|
* tests/setup/per-fork-env.ts BEFORE this module is loaded, ensuring
|
|
* apps/api/src/config.ts picks them up.
|
|
*
|
|
* Each call to `buildTestApp()` returns a fresh, fully-wired server instance
|
|
* that can be exercised with `app.inject()` (no port binding required).
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync } from "node:fs";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Ensure the workspace directory exists. The Postgres database is already
|
|
// created by per-fork-env.ts (cloned from the migrated template).
|
|
// ---------------------------------------------------------------------------
|
|
mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true });
|
|
|
|
import cookie from "@fastify/cookie";
|
|
import cors from "@fastify/cors";
|
|
import { APP_VERSION } from "@snapotter/shared";
|
|
import { eq } from "drizzle-orm";
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Import app modules. config.ts already captured our env vars.
|
|
// ---------------------------------------------------------------------------
|
|
import Fastify from "fastify";
|
|
import { afterAll } from "vitest";
|
|
import { env } from "../../apps/api/src/config.js";
|
|
import { db, schema } from "../../apps/api/src/db/index.js";
|
|
import { runMigrations } from "../../apps/api/src/db/migrate.js";
|
|
import { startCancelListener, stopCancelListener } from "../../apps/api/src/jobs/cancel.js";
|
|
import { pingRedis } from "../../apps/api/src/jobs/connection.js";
|
|
import { closeQueueEvents, warmQueueEvents } from "../../apps/api/src/jobs/enqueue.js";
|
|
import { closeWorkers, startWorkers } from "../../apps/api/src/jobs/worker.js";
|
|
import { requirePermission } from "../../apps/api/src/permissions.js";
|
|
import {
|
|
authMiddleware,
|
|
authRoutes,
|
|
ensureBuiltinRoles,
|
|
ensureDefaultAdmin,
|
|
ensureDefaultTeam,
|
|
} from "../../apps/api/src/plugins/auth.js";
|
|
import { registerIpAllowlist } from "../../apps/api/src/plugins/ip-allowlist.js";
|
|
import { registerMfa } from "../../apps/api/src/plugins/mfa.js";
|
|
import { oidcRoutes } from "../../apps/api/src/plugins/oidc.js";
|
|
import { registerPerUserRateLimit } from "../../apps/api/src/plugins/per-user-rate-limit.js";
|
|
import { registerSaml } from "../../apps/api/src/plugins/saml.js";
|
|
import { toolAccessMiddleware } from "../../apps/api/src/plugins/tool-access.js";
|
|
import { registerUpload } from "../../apps/api/src/plugins/upload.js";
|
|
import { adminOpsRoutes } from "../../apps/api/src/routes/admin-ops.js";
|
|
import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js";
|
|
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
|
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
|
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
|
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
|
import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js";
|
|
import { feedbackRoutes } from "../../apps/api/src/routes/feedback.js";
|
|
import { registerFetchUrlsRoute } from "../../apps/api/src/routes/fetch-urls.js";
|
|
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
|
import { registerJobRoutes } from "../../apps/api/src/routes/jobs.js";
|
|
import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js";
|
|
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
|
import { preferencesRoutes } from "../../apps/api/src/routes/preferences.js";
|
|
import { registerProgressRoutes } from "../../apps/api/src/routes/progress.js";
|
|
import { rolesRoutes } from "../../apps/api/src/routes/roles.js";
|
|
import { settingsRoutes } from "../../apps/api/src/routes/settings.js";
|
|
import { teamsRoutes } from "../../apps/api/src/routes/teams.js";
|
|
import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js";
|
|
import { userFileRoutes } from "../../apps/api/src/routes/user-files.js";
|
|
|
|
// Run migrations (idempotent -- template already has the schema, but this
|
|
// ensures the __drizzle_migrations journal is consistent in each fork).
|
|
await runMigrations();
|
|
|
|
// ── Job spine lifecycle (once per fork) ────────────────────────────
|
|
// Workers idle when unused; starting them for every integration file is cheap.
|
|
let spineStarted = false;
|
|
|
|
async function ensureSpine(): Promise<void> {
|
|
if (spineStarted) return;
|
|
spineStarted = true;
|
|
await startCancelListener();
|
|
startWorkers();
|
|
// Position every pool's QueueEvents consumer at the stream tail *before* the
|
|
// first job is enqueued. Without this, the first sync-wait per fork lazily
|
|
// creates a consumer that can miss a fast job's completion event and block
|
|
// for the full SYNC_WAIT_MS window -- the root cause of the csv-json 30s
|
|
// timeout flake. Awaited here so it is deterministic for the first request.
|
|
await warmQueueEvents();
|
|
}
|
|
|
|
// Module-scope afterAll: vitest registers this into any importing file's
|
|
// suite, so every fork cleans up workers and cancel listener on exit.
|
|
afterAll(async () => {
|
|
if (spineStarted) {
|
|
await closeWorkers();
|
|
await stopCancelListener();
|
|
await closeQueueEvents();
|
|
}
|
|
}, 10_000);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Pre-ready hooks: test files can push registrars here before calling
|
|
* buildTestApp(). Each hook receives the Fastify instance and can register
|
|
* extra routes (createToolRoute, etc.) before app.ready() is called.
|
|
*/
|
|
export const preReadyHooks: Array<(app: ReturnType<typeof Fastify>) => void | Promise<void>> = [];
|
|
|
|
export interface TestApp {
|
|
app: ReturnType<typeof Fastify>;
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
export async function buildTestApp(): Promise<TestApp> {
|
|
// Start the BullMQ job spine (idempotent, once per fork)
|
|
await ensureSpine();
|
|
|
|
// Seed built-in roles, the Default team, and the default admin (all idempotent)
|
|
await ensureBuiltinRoles();
|
|
await ensureDefaultTeam();
|
|
await ensureDefaultAdmin();
|
|
|
|
// Clear the mustChangePassword flag so tests can use the admin freely
|
|
await db
|
|
.update(schema.users)
|
|
.set({ mustChangePassword: false })
|
|
.where(eq(schema.users.username, "admin"));
|
|
|
|
const app = Fastify({
|
|
logger: false, // quiet during tests
|
|
bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
|
|
});
|
|
|
|
// Plugins
|
|
await app.register(cors, { origin: true });
|
|
|
|
// Multipart upload support
|
|
await registerUpload(app);
|
|
|
|
// Cookie support
|
|
await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" });
|
|
|
|
// IP allowlist (enterprise -- guards internally, returns early if not licensed)
|
|
try {
|
|
await registerIpAllowlist(app);
|
|
} catch {
|
|
// Enterprise package not available in test env
|
|
}
|
|
|
|
// Auth middleware (must be registered before routes)
|
|
await authMiddleware(app);
|
|
|
|
// Tool access gate (mirrors index.ts: after auth, before routes)
|
|
await toolAccessMiddleware(app);
|
|
|
|
// Per-user rate limiting (after auth so request.user is populated)
|
|
try {
|
|
await registerPerUserRateLimit(app);
|
|
} catch {
|
|
// Redis may not be fully available in all test scenarios
|
|
}
|
|
|
|
// Auth routes
|
|
await authRoutes(app);
|
|
|
|
// OIDC routes
|
|
await oidcRoutes(app);
|
|
|
|
// SAML routes (enterprise -- guards internally, returns early if not licensed)
|
|
try {
|
|
await registerSaml(app);
|
|
} catch {
|
|
// Enterprise package not available in test env
|
|
}
|
|
|
|
// MFA routes (TOTP enrollment, verification, disable)
|
|
try {
|
|
await registerMfa(app);
|
|
} catch {
|
|
// MFA dependencies may not be available in test env
|
|
}
|
|
|
|
// File upload/download routes
|
|
await fileRoutes(app);
|
|
|
|
// User file library routes (persistent file management with versioning)
|
|
await userFileRoutes(app);
|
|
|
|
// Meme template routes
|
|
await registerMemeTemplates(app);
|
|
|
|
// Tool routes
|
|
await registerToolRoutes(app);
|
|
|
|
// Batch processing routes
|
|
await registerBatchRoutes(app);
|
|
|
|
// URL fetch routes
|
|
await registerFetchUrlsRoute(app);
|
|
|
|
// Pipeline routes
|
|
await registerPipelineRoutes(app);
|
|
|
|
// Progress SSE routes
|
|
await registerProgressRoutes(app);
|
|
|
|
// Job control routes (cancel) -- shares the production handler so the
|
|
// ownership check is exercised by integration tests, not a divergent copy.
|
|
registerJobRoutes(app);
|
|
|
|
// API key management routes
|
|
await apiKeyRoutes(app);
|
|
|
|
// Per-user preferences
|
|
await preferencesRoutes(app);
|
|
|
|
// Settings routes
|
|
await settingsRoutes(app);
|
|
|
|
// Teams routes
|
|
await teamsRoutes(app);
|
|
|
|
// Audit log routes
|
|
await auditLogRoutes(app);
|
|
|
|
// Roles management routes
|
|
await rolesRoutes(app);
|
|
|
|
// Admin ops routes (runtime log level, Prometheus metrics)
|
|
await adminOpsRoutes(app);
|
|
|
|
// Enterprise routes (license-gated features)
|
|
await registerEnterpriseRoutes(app);
|
|
|
|
// Analytics routes
|
|
await analyticsRoutes(app);
|
|
|
|
// Explicit customer feedback capture
|
|
await feedbackRoutes(app);
|
|
|
|
// API docs (Scalar)
|
|
await docsRoutes(app);
|
|
|
|
// Public health check (minimal - no internal details)
|
|
app.get("/api/v1/health", async () => ({
|
|
status: "healthy",
|
|
version: APP_VERSION,
|
|
}));
|
|
|
|
// Admin health check (full diagnostics)
|
|
app.get("/api/v1/admin/health", async (request, reply) => {
|
|
const admin = requirePermission("system:health")(request, reply);
|
|
if (!admin) return;
|
|
|
|
let dbOk = false;
|
|
try {
|
|
await db.select().from(schema.settings).limit(1);
|
|
dbOk = true;
|
|
} catch {
|
|
/* db unreachable */
|
|
}
|
|
return {
|
|
status: dbOk ? "healthy" : "degraded",
|
|
version: APP_VERSION,
|
|
uptime: `${process.uptime().toFixed(0)}s`,
|
|
storage: { mode: env.STORAGE_MODE, available: "N/A" },
|
|
database: dbOk ? "ok" : "error",
|
|
queue: { active: 0, pending: 0 },
|
|
ai: {},
|
|
};
|
|
});
|
|
|
|
// Public config endpoint
|
|
app.get("/api/v1/config/auth", async () => {
|
|
const config: Record<string, unknown> = { authEnabled: env.AUTH_ENABLED };
|
|
if (env.OIDC_ENABLED) {
|
|
config.oidcEnabled = true;
|
|
config.oidcProviderName = env.OIDC_PROVIDER_NAME || null;
|
|
config.oidcLoginUrl = "/api/auth/oidc/login";
|
|
}
|
|
config.samlEnabled = false;
|
|
config.samlProviderName = "";
|
|
config.samlLoginUrl = "";
|
|
config.ssoEnforced = false;
|
|
return config;
|
|
});
|
|
|
|
// Readiness probe (no auth)
|
|
app.get("/api/v1/readyz", async (_request, reply) => {
|
|
let postgres = false;
|
|
let redis = false;
|
|
try {
|
|
await db.select().from(schema.settings).limit(1);
|
|
postgres = true;
|
|
} catch {
|
|
/* db unreachable */
|
|
}
|
|
try {
|
|
redis = await pingRedis();
|
|
} catch {
|
|
/* redis unreachable */
|
|
}
|
|
const ok = postgres && redis;
|
|
return reply.code(ok ? 200 : 503).send({ ok, postgres, redis });
|
|
});
|
|
|
|
// Run pre-ready hooks (test files register extra routes here)
|
|
for (const hook of preReadyHooks) {
|
|
await hook(app);
|
|
}
|
|
preReadyHooks.length = 0;
|
|
|
|
// Ensure Fastify is ready (all plugins loaded)
|
|
await app.ready();
|
|
|
|
const cleanup = async () => {
|
|
await app.close();
|
|
// The pg pool is a module-level singleton shared across the fork.
|
|
// Do NOT call closeDb() here; let the fork process exit naturally.
|
|
// Closing prematurely would break tests that run DB queries after
|
|
// the app is closed (e.g. verifying DB state in assertions).
|
|
};
|
|
|
|
return { app, cleanup };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Log in as the default admin and return the session token. */
|
|
export async function loginAsAdmin(app: ReturnType<typeof Fastify>): Promise<string> {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: {
|
|
username: "admin",
|
|
password: "Adminpass1",
|
|
},
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
if (!body.token) {
|
|
throw new Error(`Login failed: ${res.body}`);
|
|
}
|
|
return body.token as string;
|
|
}
|
|
|
|
/**
|
|
* Create (idempotently) a non-admin account with the given role and return its
|
|
* session token together with its user id. The account is created through the
|
|
* real admin-gated register route (`POST /api/auth/register`, permission
|
|
* `users:manage`), so the helper proves the route shape rather than poking the
|
|
* DB directly. The register route sets `mustChangePassword: true`, which the
|
|
* auth middleware would otherwise turn into a 403 on every non-auth API call
|
|
* (SKIP_MUST_CHANGE_PASSWORD defaults to false in tests), so we clear that flag
|
|
* the same way the admin seed does.
|
|
*/
|
|
export async function createUserAndLogin(
|
|
app: ReturnType<typeof Fastify>,
|
|
username: string,
|
|
role = "user",
|
|
password = "Userpass1",
|
|
): Promise<{ token: string; userId: string }> {
|
|
const adminToken = await loginAsAdmin(app);
|
|
|
|
// Create the user. A 409 means a prior call already created it -- tolerate it.
|
|
const create = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/register",
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { username, password, role },
|
|
});
|
|
if (create.statusCode !== 201 && create.statusCode !== 409) {
|
|
throw new Error(`User create failed (${create.statusCode}): ${create.body}`);
|
|
}
|
|
|
|
// Clear the first-login password-change gate so the account can reach normal
|
|
// permission-checked routes (idempotent if already cleared).
|
|
await db
|
|
.update(schema.users)
|
|
.set({ mustChangePassword: false })
|
|
.where(eq(schema.users.username, username));
|
|
|
|
const [row] = await db
|
|
.select({ id: schema.users.id })
|
|
.from(schema.users)
|
|
.where(eq(schema.users.username, username));
|
|
if (!row) {
|
|
throw new Error(`User ${username} not found after create`);
|
|
}
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/auth/login",
|
|
payload: { username, password },
|
|
});
|
|
const body = JSON.parse(res.body);
|
|
if (!body.token) {
|
|
throw new Error(`User login failed: ${res.body}`);
|
|
}
|
|
return { token: body.token as string, userId: row.id };
|
|
}
|
|
|
|
/**
|
|
* Log in as a default non-admin `user`-role account, returning its session
|
|
* token. Thin wrapper over {@link createUserAndLogin} kept for existing callers.
|
|
*/
|
|
export async function loginAsUser(app: ReturnType<typeof Fastify>): Promise<string> {
|
|
const { token } = await createUserAndLogin(app, "plainuser");
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Build a multipart/form-data payload for use with `app.inject()`.
|
|
*
|
|
* Fastify's `inject()` doesn't natively support FormData, so we construct
|
|
* the raw multipart body with proper boundaries manually.
|
|
*/
|
|
export function createMultipartPayload(
|
|
fields: Array<{
|
|
name: string;
|
|
filename?: string;
|
|
contentType?: string;
|
|
content: Buffer | string;
|
|
}>,
|
|
): { body: Buffer; contentType: string } {
|
|
const boundary = `----TestBoundary${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
const parts: Buffer[] = [];
|
|
|
|
for (const field of fields) {
|
|
let header = `--${boundary}\r\n`;
|
|
if (field.filename) {
|
|
header += `Content-Disposition: form-data; name="${field.name}"; filename="${field.filename}"\r\n`;
|
|
header += `Content-Type: ${field.contentType || "application/octet-stream"}\r\n`;
|
|
} else {
|
|
header += `Content-Disposition: form-data; name="${field.name}"\r\n`;
|
|
}
|
|
header += "\r\n";
|
|
parts.push(Buffer.from(header));
|
|
parts.push(Buffer.isBuffer(field.content) ? field.content : Buffer.from(field.content));
|
|
parts.push(Buffer.from("\r\n"));
|
|
}
|
|
|
|
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
|
|
return {
|
|
body: Buffer.concat(parts),
|
|
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
};
|
|
}
|