forked from jappel/leistungsbilanz-ts
Harden user management integration
This commit is contained in:
parent
73579d1e79
commit
847f4e9493
18 changed files with 394 additions and 111 deletions
|
|
@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j
|
|||
import type { AuthConfig } from "../src/server/auth/auth-config.js";
|
||||
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
|
||||
import { loginSchema } from "../src/shared/validation/auth.schemas.js";
|
||||
import {
|
||||
WRITE_REQUEST_HEADER_NAME,
|
||||
WRITE_REQUEST_HEADER_VALUE,
|
||||
} from "../src/shared/constants/request-security.js";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
|
|
@ -34,7 +38,14 @@ function createUserService() {
|
|||
cookieSecure: false,
|
||||
bootstrap: { username: "admin", password: "bootstrap-password-123" },
|
||||
};
|
||||
return { repository, service: new UserService(repository, config) };
|
||||
return {
|
||||
repository,
|
||||
service: new UserService(
|
||||
repository,
|
||||
{ bootstrap: config.bootstrap },
|
||||
{ hash: hashPassword, verify: verifyPassword }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("password hashing", () => {
|
||||
|
|
@ -52,6 +63,17 @@ describe("password hashing", () => {
|
|||
it("rejects malformed stored hashes instead of throwing", async () => {
|
||||
assert.equal(await verifyPassword("x", "not-a-hash"), false);
|
||||
assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false);
|
||||
assert.equal(
|
||||
await verifyPassword(
|
||||
"x",
|
||||
`scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}`
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -135,12 +157,20 @@ describe("cookie parsing", () => {
|
|||
});
|
||||
|
||||
describe("same-origin guard for writes", () => {
|
||||
function run(method: string, fetchSite?: string) {
|
||||
function run(method: string, fetchSite?: string, includeApplicationHeader = true) {
|
||||
let status: number | undefined;
|
||||
let passed = false;
|
||||
const req = {
|
||||
method,
|
||||
headers: fetchSite ? { "sec-fetch-site": fetchSite } : {},
|
||||
headers: {
|
||||
...(fetchSite ? { "sec-fetch-site": fetchSite } : {}),
|
||||
...(includeApplicationHeader
|
||||
? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE }
|
||||
: {}),
|
||||
},
|
||||
get(name: string) {
|
||||
return this.headers[name.toLowerCase() as keyof typeof this.headers];
|
||||
},
|
||||
} as unknown as Request;
|
||||
const res = {
|
||||
status(code: number) {
|
||||
|
|
@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => {
|
|||
}
|
||||
|
||||
it("lets reads and same-origin writes through", () => {
|
||||
assert.equal(run("GET", "cross-site").passed, true);
|
||||
assert.equal(run("GET", "cross-site", false).passed, true);
|
||||
assert.equal(run("POST", "same-origin").passed, true);
|
||||
assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie");
|
||||
assert.equal(run("POST").passed, true, "scripts can send the explicit application header");
|
||||
});
|
||||
|
||||
it("rejects writes from other sites and other ports of the same host", () => {
|
||||
it("rejects writes without the application header or from another origin", () => {
|
||||
assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 });
|
||||
assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 });
|
||||
assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 });
|
||||
assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 });
|
||||
|
|
@ -239,6 +270,30 @@ describe("user service", () => {
|
|||
await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError);
|
||||
});
|
||||
|
||||
it("preserves one active admin across concurrent updates", async () => {
|
||||
const { repository, service } = createUserService();
|
||||
await service.ensureBootstrapAdmin();
|
||||
const second = await service.createUser({
|
||||
username: "zweiter-admin",
|
||||
name: "Zweiter Admin",
|
||||
role: "admin",
|
||||
initialPassword: "start-passwort",
|
||||
});
|
||||
assert.ok(second);
|
||||
const [first] = (await repository.list()).filter((user) => user.username === "admin");
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
service.updateRoleAndActive(first.id, { role: "user" }),
|
||||
service.updateRoleAndActive(second.id, { role: "user" }),
|
||||
]);
|
||||
assert.equal(results.filter((result) => result.status === "fulfilled").length, 1);
|
||||
assert.equal(results.filter((result) => result.status === "rejected").length, 1);
|
||||
const activeAdmins = (await repository.list()).filter(
|
||||
(user) => user.role === "admin" && user.active
|
||||
);
|
||||
assert.equal(activeAdmins.length, 1);
|
||||
});
|
||||
|
||||
it("requires the current password and a different new one", async () => {
|
||||
const { service } = createUserService();
|
||||
const user = await service.createUser({
|
||||
|
|
|
|||
26
tests/config-access.test.ts
Normal file
26
tests/config-access.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
getConfigEntry,
|
||||
getConfigRedirect,
|
||||
getConfigTabs,
|
||||
} from "../src/frontend/utils/config-access.js";
|
||||
|
||||
describe("config access", () => {
|
||||
it("offers account settings to every signed-in user", () => {
|
||||
assert.deepEqual(getConfigEntry(false), { href: "/config/konto", label: "Konto" });
|
||||
assert.deepEqual(getConfigTabs(false), [{ href: "/config/konto", label: "Konto" }]);
|
||||
assert.equal(getConfigRedirect("/config", false), "/config/konto");
|
||||
assert.equal(getConfigRedirect("/config/konto", false), null);
|
||||
});
|
||||
|
||||
it("keeps user administration admin-only", () => {
|
||||
assert.equal(getConfigRedirect("/config/benutzer", false), "/config/konto");
|
||||
assert.deepEqual(getConfigTabs(true), [
|
||||
{ href: "/config/benutzer", label: "Benutzer" },
|
||||
{ href: "/config/konto", label: "Konto" },
|
||||
]);
|
||||
assert.deepEqual(getConfigEntry(true), { href: "/config/benutzer", label: "Config" });
|
||||
assert.equal(getConfigRedirect("/config/benutzer", true), null);
|
||||
});
|
||||
});
|
||||
40
tests/write-request-headers.test.ts
Normal file
40
tests/write-request-headers.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { authApi } from "../src/frontend/utils/auth-api.js";
|
||||
import { createProject } from "../src/frontend/utils/api.js";
|
||||
import { calculateCableSizing } from "../src/frontend/components/cable-sizing-api.js";
|
||||
import type { CableSizingInput } from "../src/cable-sizing/domain/cable-sizing-calculation.js";
|
||||
import {
|
||||
WRITE_REQUEST_HEADER_NAME,
|
||||
WRITE_REQUEST_HEADER_VALUE,
|
||||
} from "../src/shared/constants/request-security.js";
|
||||
|
||||
describe("write request clients", () => {
|
||||
it("send the application header from every frontend API client", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: RequestInit[] = [];
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
requests.push(init ?? {});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await authApi.login("admin", "start-passwort");
|
||||
await createProject("Testprojekt");
|
||||
await calculateCableSizing({} as CableSizingInput);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(requests.length, 3);
|
||||
for (const request of requests) {
|
||||
assert.equal(
|
||||
new Headers(request.headers).get(WRITE_REQUEST_HEADER_NAME),
|
||||
WRITE_REQUEST_HEADER_VALUE
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue