mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Follow-up to a full re-audit of the 2.0 tree. Most prior findings were already fixed; this closes the ones that were not: - SAML assertion replay: validateInResponseTo ifPresent plus a Redis-backed CacheProvider, so a captured signed assertion cannot be replayed. ifPresent keeps IdP-initiated SSO working. - MFA login challenge burned after 5 wrong TOTP codes. - api_keys.key_prefix indexed; the per-request lookup was a full table scan. - MAX_AI_JOBS_PER_USER caps a user's in-flight single-file AI jobs (the AI pool runs at concurrency 1). Batch and pipeline AI stay uncapped. - MAX_WORKSPACE_SIZE_GB enforced instead of being dead config. - SUBPROCESS_MEMORY_LIMIT_MB (default off) for the native media and doc engines; not applied to the AI sidecar. - SVG sanitizer closes unquoted and whitespace-prefixed javascript: hrefs and the animateTransform/animateMotion/handler/mpath elements. - Windows-style paths stripped from error output to match the Sentry scrubber. - Postgres and Redis compose services get cap_drop plus pids_limit and cpus. - .env.example ships MAX_SVG_SIZE_MB=50 (0 disabled the cap). Adds security-focused unit and integration tests. typecheck, biome, and the full unit and integration suites pass.
80 lines
2.6 KiB
TypeScript
80 lines
2.6 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
const { buildTestApp, loginAsAdmin } = await import("../test-server.js");
|
|
const { db, schema } = await import("../../../apps/api/src/db/index.js");
|
|
const { countInFlightAiJobs } = await import("../../../apps/api/src/lib/ai-quota.js");
|
|
const { enqueueToolJob } = await import("../../../apps/api/src/jobs/enqueue.js");
|
|
const { env } = await import("../../../apps/api/src/config.js");
|
|
|
|
import type { TestApp } from "../test-server.js";
|
|
|
|
let testApp: TestApp;
|
|
let adminId: string;
|
|
|
|
async function seedAiJob(userId: string, status: "queued" | "processing", kind: string) {
|
|
const id = `test-aijob-${Math.random().toString(36).slice(2)}`;
|
|
await db.insert(schema.jobs).values({
|
|
id,
|
|
userId,
|
|
toolId: "colorize",
|
|
pool: "ai",
|
|
type: kind,
|
|
status,
|
|
inputRefs: [`uploads/${id}/x.png`],
|
|
settings: {},
|
|
});
|
|
return id;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
await loginAsAdmin(testApp.app);
|
|
const [admin] = await db.select().from(schema.users).where(eq(schema.users.username, "admin"));
|
|
adminId = admin.id;
|
|
}, 30_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(schema.jobs).where(and(eq(schema.jobs.userId, adminId)));
|
|
});
|
|
|
|
describe("countInFlightAiJobs", () => {
|
|
it("counts only queued/processing ai-tool jobs, ignoring other kinds and terminal states", async () => {
|
|
await seedAiJob(adminId, "queued", "ai-tool");
|
|
await seedAiJob(adminId, "processing", "ai-tool");
|
|
await seedAiJob(adminId, "queued", "batch-child"); // different kind: excluded
|
|
const done = await seedAiJob(adminId, "queued", "ai-tool");
|
|
await db.update(schema.jobs).set({ status: "completed" }).where(eq(schema.jobs.id, done));
|
|
|
|
expect(await countInFlightAiJobs(adminId)).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("enqueueToolJob AI quota enforcement", () => {
|
|
it("rejects a new ai-tool job with 429 once the user is at the cap", async () => {
|
|
const cap = env.MAX_AI_JOBS_PER_USER;
|
|
expect(cap).toBeGreaterThan(0);
|
|
for (let i = 0; i < cap; i++) await seedAiJob(adminId, "queued", "ai-tool");
|
|
|
|
await expect(
|
|
enqueueToolJob({
|
|
jobId: "test-over-cap-job",
|
|
toolId: "colorize",
|
|
userId: adminId,
|
|
pool: "ai",
|
|
inputRefs: ["uploads/test-over-cap-job/x.png"],
|
|
filename: "x.png",
|
|
settings: {},
|
|
kind: "ai-tool",
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 429 });
|
|
|
|
// The rejected job left no row behind.
|
|
const [row] = await db
|
|
.select()
|
|
.from(schema.jobs)
|
|
.where(eq(schema.jobs.id, "test-over-cap-job"));
|
|
expect(row).toBeUndefined();
|
|
});
|
|
});
|