157 lines
4.9 KiB
TypeScript
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" }),
|
|
);
|
|
});
|