taskmanager/tests/security.test.ts

157 lines
4.9 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { allowed, taskScope, type Principal } from "../src/lib/rbac";
import { validPassword } from "../src/lib/security";
import { boundedBody, HttpError, sameOrigin } from "../src/lib/http";
import { storagePath, saveFile } from "../src/lib/storage";
import { taskPatch } from "../src/lib/task-service";
import {
readWorkbook,
writeWorkbook,
validateWorkbook,
} from "../src/lib/spreadsheet-transfer";
test("Excel roundtrip preserves literal formula-like text without evaluating it", async () => {
const bytes = await writeWorkbook([
{ was: "=1+1", wo: "Büro", bisWann: "2026-10-01", owningGroupId: "a" },
]);
const rows = await readWorkbook(bytes);
assert.equal(rows[0].was, "=1+1");
assert.equal(rows[0].wo, "Büro");
});
test("invalid and oversized workbooks are rejected before parsing", async () => {
await assert.rejects(
() => validateWorkbook(Buffer.from("not a zip")),
HttpError,
);
await assert.rejects(() => validateWorkbook(Buffer.alloc(100001)), HttpError);
});
test("partial updates preserve assignment, priority, recurrence and checklist", () => {
assert.deepEqual(taskPatch.parse({ version: 1, was: "Changed" }), {
version: 1,
was: "Changed",
});
assert.deepEqual(taskPatch.parse({ version: 2, status: "ERLEDIGT" }), {
version: 2,
status: "ERLEDIGT",
});
});
const actor: Principal = {
id: "alice",
grants: [
{
permission: "tasks.read",
scope: "GROUP",
groupId: "a",
source: "Team A",
},
{
permission: "tasks.update",
scope: "ASSIGNED",
groupId: "",
source: "Worker",
},
],
};
test("group grants never cross group boundaries", () => {
assert.equal(allowed(actor, "tasks.read", { owningGroupId: "a" }), true);
assert.equal(allowed(actor, "tasks.read", { owningGroupId: "b" }), false);
});
test("assigned scope requires assignment, not creation", () => {
assert.equal(allowed(actor, "tasks.update", { assigneeId: "alice" }), true);
assert.equal(allowed(actor, "tasks.update", { assigneeId: "bob" }), false);
assert.equal(allowed(actor, "tasks.delete", { assigneeId: "alice" }), false);
});
test("unassigned or revoked grants fail closed", () => {
assert.equal(
allowed({ id: "alice", grants: [] }, "tasks.read", { assigneeId: "alice" }),
false,
);
assert.deepEqual(taskScope({ id: "alice", grants: [] }), { OR: [] });
});
test("password-change requirement denies capabilities", () => {
assert.equal(
allowed({ ...actor, mustChangePassword: true }, "tasks.read", {
owningGroupId: "a",
}),
false,
);
});
test("password policy checks UTF-8 bytes", () => {
assert.equal(validPassword("short"), false);
assert.equal(validPassword("long-and-valid-password"), true);
assert.equal(validPassword("🔑".repeat(19)), false);
});
test("request size is enforced for chunked bodies without content-length", async () => {
await assert.rejects(
() =>
boundedBody(
new Request("http://localhost", {
method: "POST",
body: "x".repeat(1025),
}),
1024,
),
(e) => e instanceof HttpError && e.status === 413,
);
});
test("cross-origin cookie mutations are rejected", () => {
assert.throws(
() =>
sameOrigin(
new Request("http://localhost:3000/api/tasks", {
method: "POST",
headers: { origin: "https://evil.example" },
}),
),
HttpError,
);
});
test("storage paths reject traversal and public historical paths", () => {
assert.throws(() => storagePath("../../secret"), HttpError);
assert.throws(() => storagePath("/uploads/task/file.pdf"), HttpError);
});
test("claimed MIME type cannot permit HTML upload", async () => {
await assert.rejects(
() => saveFile(Buffer.from("<!doctype html><script>alert(1)</script>")),
HttpError,
);
});
import { validateProductionConfig } from "../src/lib/runtime-config";
test("production config restricts HTTP exceptions to loopback and rejects partial integrations", () => {
const base = {
NEXTAUTH_SECRET: "test-only-secret-12345678901234567890",
NEXTAUTH_URL: "https://tasks.example.test",
};
assert.doesNotThrow(() => validateProductionConfig(base));
assert.doesNotThrow(() =>
validateProductionConfig({
...base,
NEXTAUTH_URL: "http://localhost:3110",
ALLOW_LOCAL_HTTP: "true",
}),
);
assert.throws(() =>
validateProductionConfig({
...base,
NEXTAUTH_URL: "http://tasks.example.test",
ALLOW_LOCAL_HTTP: "true",
}),
);
assert.throws(() =>
validateProductionConfig({
...base,
OIDC_ISSUER: "https://id.example.test",
}),
);
assert.throws(() =>
validateProductionConfig({ ...base, SMTP_URL: "smtp://mail.example.test" }),
);
assert.throws(() =>
validateProductionConfig({ ...base, ARCHIVE_RETENTION_DAYS: "-1" }),
);
assert.throws(() =>
validateProductionConfig({ ...base, GROUP_UPLOAD_QUOTA_MB: "NaN" }),
);
});