Files
SnapOtter/tests/integration/security/security-rate-limiting.test.ts
T

140 lines
4.3 KiB
TypeScript
Raw Normal View History

/**
* Integration tests for security upload controls:
* - Upload route has rate limit config (M13)
* - Per-user storage quota enforcement (L3)
*
* Note: Rate limit enforcement requires @fastify/rate-limit plugin which
* the test server does not register. These tests verify the routes accept
* requests correctly and that quota logic is wired up. Full rate limit
* enforcement is tested via the production server which registers the plugin.
*/
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/**
* Create a minimal valid PNG buffer (1x1 pixel, transparent).
*/
function createMinimalPng(): Buffer {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
}
describe("Upload endpoint accepts valid files (M13 rate limit configured)", () => {
it("accepts authenticated uploads to /api/v1/files/upload", async () => {
const png = createMinimalPng();
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: png },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/files/upload",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
payload: body,
});
expect(res.statusCode).toBe(201);
const resBody = JSON.parse(res.body);
expect(resBody.files).toBeDefined();
expect(resBody.files.length).toBe(1);
});
it("accepts uploads to /api/v1/upload", async () => {
const png = createMinimalPng();
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test-upload.png", contentType: "image/png", content: png },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/upload",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
payload: body,
});
expect(res.statusCode).toBe(200);
const resBody = JSON.parse(res.body);
expect(resBody.jobId).toBeDefined();
expect(resBody.files).toBeDefined();
});
});
describe("Per-user storage quota enforcement (L3)", () => {
it("accepts uploads when within storage quota", async () => {
const png = createMinimalPng();
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "quota-ok.png", contentType: "image/png", content: png },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/files/upload",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
payload: body,
});
expect(res.statusCode).toBe(201);
});
it("tracks file sizes in the database for quota calculation", async () => {
// Verify we can query total storage per user (the quota check mechanism)
const [adminUser] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, "admin"));
expect(adminUser).toBeDefined();
const [result] = await db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, adminUser?.id ?? ""));
// Should have some bytes from the uploads in previous tests
expect(result).toBeDefined();
// Postgres returns SUM as string (bigint); coerce for comparison
expect(Number(result?.total)).toBeGreaterThanOrEqual(0);
});
it("quota check query returns 0 for users with no files", async () => {
const [result] = await db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, "nonexistent-user-id"));
expect(result).toBeDefined();
// Postgres returns SUM as string (bigint); coerce for comparison
expect(Number(result?.total)).toBe(0);
});
});