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
|
|
@ -4,26 +4,27 @@ import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuth } from "../../frontend/components/AuthProvider";
|
import { useAuth } from "../../frontend/components/AuthProvider";
|
||||||
|
import {
|
||||||
const CONFIG_TABS = [
|
getConfigRedirect,
|
||||||
{ href: "/config/benutzer", label: "Benutzer" },
|
getConfigTabs,
|
||||||
{ href: "/config/konto", label: "Konto" },
|
} from "../../frontend/utils/config-access";
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
|
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { status, isAdmin } = useAuth();
|
const { status, isAdmin } = useAuth();
|
||||||
|
const tabs = getConfigTabs(isAdmin);
|
||||||
|
const redirectTarget = getConfigRedirect(pathname, isAdmin);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// LoginGate already guarantees a signed-in user with a set password by
|
if (status !== "signed-in") return;
|
||||||
// the time this layout renders; only the role still needs checking.
|
if (redirectTarget) router.replace(redirectTarget);
|
||||||
if (status === "signed-in" && !isAdmin) {
|
}, [status, redirectTarget, router]);
|
||||||
router.replace("/");
|
|
||||||
}
|
|
||||||
}, [status, isAdmin, router]);
|
|
||||||
|
|
||||||
if (status !== "signed-in" || !isAdmin) {
|
if (
|
||||||
|
status !== "signed-in" ||
|
||||||
|
redirectTarget !== null
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ export default function ConfigLayout({ children }: { children: React.ReactNode }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="config-tabs">
|
<nav className="config-tabs">
|
||||||
{CONFIG_TABS.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Link
|
<Link
|
||||||
key={tab.href}
|
key={tab.href}
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
redirect("/config/benutzer");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { users } from "../schema/users.js";
|
import { users } from "../schema/users.js";
|
||||||
|
import {
|
||||||
|
LastActiveAdminStoreError,
|
||||||
|
UsernameAlreadyExistsStoreError,
|
||||||
|
type CreateUserAccount,
|
||||||
|
type UserStore,
|
||||||
|
} from "../../domain/ports/user.store.js";
|
||||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||||
|
|
||||||
export interface CreateUserRow {
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
username: string;
|
return (
|
||||||
name: string;
|
error instanceof Error &&
|
||||||
role: UserRole;
|
"code" in error &&
|
||||||
passwordHash: string;
|
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserRepository {
|
export class UserRepository implements UserStore {
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
|
|
@ -41,18 +48,23 @@ export class UserRepository {
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateUserRow) {
|
async create(input: CreateUserAccount) {
|
||||||
const db = this.database;
|
const db = this.database;
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await db.insert(users).values({
|
try {
|
||||||
id,
|
await db.insert(users).values({
|
||||||
username: input.username.trim().toLowerCase(),
|
id,
|
||||||
name: input.name,
|
username: input.username.trim().toLowerCase(),
|
||||||
role: input.role,
|
name: input.name,
|
||||||
passwordHash: input.passwordHash,
|
role: input.role,
|
||||||
active: true,
|
passwordHash: input.passwordHash,
|
||||||
mustChangePassword: true,
|
active: true,
|
||||||
});
|
mustChangePassword: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,16 +72,34 @@ export class UserRepository {
|
||||||
userId: string,
|
userId: string,
|
||||||
patch: { role?: UserRole; active?: boolean }
|
patch: { role?: UserRole; active?: boolean }
|
||||||
) {
|
) {
|
||||||
const db = this.database;
|
return this.database.transaction((tx) => {
|
||||||
await db
|
const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all();
|
||||||
.update(users)
|
if (!target) return null;
|
||||||
.set({
|
|
||||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
const removesActiveAdmin =
|
||||||
...(patch.active !== undefined ? { active: patch.active } : {}),
|
target.role === "admin" &&
|
||||||
updatedAt: new Date(),
|
target.active &&
|
||||||
})
|
(patch.role === "user" || patch.active === false);
|
||||||
.where(eq(users.id, userId));
|
if (removesActiveAdmin) {
|
||||||
return this.findById(userId);
|
const activeAdmins = tx
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.role, "admin"), eq(users.active, true)))
|
||||||
|
.limit(2)
|
||||||
|
.all();
|
||||||
|
if (activeAdmins.length <= 1) throw new LastActiveAdminStoreError();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(users)
|
||||||
|
.set({
|
||||||
|
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||||
|
...(patch.active !== undefined ? { active: patch.active } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.run();
|
||||||
|
return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPassword(
|
async setPassword(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||||
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||||
|
|
||||||
export const users = sqliteTable(
|
export const users = sqliteTable(
|
||||||
"users",
|
"users",
|
||||||
|
|
@ -8,7 +9,7 @@ export const users = sqliteTable(
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
passwordHash: text("password_hash").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
role: text("role").notNull().default("user"),
|
role: text("role").$type<UserRole>().notNull().default("user"),
|
||||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
mustChangePassword: integer("must_change_password", { mode: "boolean" })
|
mustChangePassword: integer("must_change_password", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|
|
||||||
40
src/domain/ports/user.store.ts
Normal file
40
src/domain/ports/user.store.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||||
|
|
||||||
|
export interface UserAccount {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
active: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserAccount {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsernameAlreadyExistsStoreError extends Error {}
|
||||||
|
export class LastActiveAdminStoreError extends Error {}
|
||||||
|
|
||||||
|
export interface UserStore {
|
||||||
|
list(): Promise<UserAccount[]>;
|
||||||
|
count(): Promise<number>;
|
||||||
|
findById(userId: string): Promise<UserAccount | null>;
|
||||||
|
findByUsername(username: string): Promise<UserAccount | null>;
|
||||||
|
create(input: CreateUserAccount): Promise<UserAccount | null>;
|
||||||
|
updateRoleAndActive(
|
||||||
|
userId: string,
|
||||||
|
patch: { role?: UserRole; active?: boolean }
|
||||||
|
): Promise<UserAccount | null>;
|
||||||
|
setPassword(
|
||||||
|
userId: string,
|
||||||
|
passwordHash: string,
|
||||||
|
mustChangePassword: boolean
|
||||||
|
): Promise<UserAccount | null>;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { UserRepository } from "../../db/repositories/user.repository.js";
|
|
||||||
import { hashPassword, verifyPassword } from "../../server/auth/password.js";
|
|
||||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||||
import type { AuthConfig } from "../../server/auth/auth-config.js";
|
|
||||||
import { createLogger } from "../../shared/logging/logger.js";
|
import { createLogger } from "../../shared/logging/logger.js";
|
||||||
|
import {
|
||||||
|
LastActiveAdminStoreError,
|
||||||
|
UsernameAlreadyExistsStoreError,
|
||||||
|
type UserStore,
|
||||||
|
} from "../ports/user.store.js";
|
||||||
|
|
||||||
const logger = createLogger("user-service");
|
const logger = createLogger("user-service");
|
||||||
|
|
||||||
|
|
@ -36,20 +38,22 @@ export class LastAdminError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown): boolean {
|
export interface PasswordHasher {
|
||||||
return (
|
hash(password: string): Promise<string>;
|
||||||
error instanceof Error &&
|
verify(password: string, storedHash: string): Promise<boolean>;
|
||||||
"code" in error &&
|
}
|
||||||
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
|
||||||
);
|
export interface UserServiceOptions {
|
||||||
|
bootstrap: { username: string; password: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
private dummyHash: Promise<string> | null = null;
|
private dummyHash: Promise<string> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: UserRepository,
|
private readonly repository: UserStore,
|
||||||
private readonly authConfig: AuthConfig
|
private readonly options: UserServiceOptions,
|
||||||
|
private readonly passwords: PasswordHasher
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,21 +69,21 @@ export class UserService {
|
||||||
await this.getDummyHash();
|
await this.getDummyHash();
|
||||||
const existingCount = await this.repository.count();
|
const existingCount = await this.repository.count();
|
||||||
if (existingCount > 0) return;
|
if (existingCount > 0) return;
|
||||||
if (!this.authConfig.bootstrap) {
|
if (!this.options.bootstrap) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in"
|
"no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const passwordHash = await hashPassword(this.authConfig.bootstrap.password);
|
const passwordHash = await this.passwords.hash(this.options.bootstrap.password);
|
||||||
await this.repository.create({
|
await this.repository.create({
|
||||||
username: this.authConfig.bootstrap.username,
|
username: this.options.bootstrap.username,
|
||||||
name: "Administrator",
|
name: "Administrator",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
logger.info("bootstrap admin created", {
|
logger.info("bootstrap admin created", {
|
||||||
username: this.authConfig.bootstrap.username,
|
username: this.options.bootstrap.username,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +93,7 @@ export class UserService {
|
||||||
// scrypt run either way, so response time doesn't reveal which usernames
|
// scrypt run either way, so response time doesn't reveal which usernames
|
||||||
// exist.
|
// exist.
|
||||||
const passwordHash = user?.passwordHash ?? (await this.getDummyHash());
|
const passwordHash = user?.passwordHash ?? (await this.getDummyHash());
|
||||||
const passwordMatches = await verifyPassword(password, passwordHash);
|
const passwordMatches = await this.passwords.verify(password, passwordHash);
|
||||||
if (!user || !user.active || !passwordMatches) {
|
if (!user || !user.active || !passwordMatches) {
|
||||||
throw new InvalidCredentialsError();
|
throw new InvalidCredentialsError();
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +108,7 @@ export class UserService {
|
||||||
}) {
|
}) {
|
||||||
const existing = await this.repository.findByUsername(input.username);
|
const existing = await this.repository.findByUsername(input.username);
|
||||||
if (existing) throw new UsernameTakenError();
|
if (existing) throw new UsernameTakenError();
|
||||||
const passwordHash = await hashPassword(input.initialPassword);
|
const passwordHash = await this.passwords.hash(input.initialPassword);
|
||||||
try {
|
try {
|
||||||
return await this.repository.create({
|
return await this.repository.create({
|
||||||
username: input.username,
|
username: input.username,
|
||||||
|
|
@ -114,29 +118,23 @@ export class UserService {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Two admins creating the same name at once: the unique index decides.
|
// Two admins creating the same name at once: the unique index decides.
|
||||||
if (isUniqueConstraintError(error)) throw new UsernameTakenError();
|
if (error instanceof UsernameAlreadyExistsStoreError) throw new UsernameTakenError();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
|
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
|
||||||
const removesAdmin = patch.role === "user" || patch.active === false;
|
try {
|
||||||
if (removesAdmin) {
|
return await this.repository.updateRoleAndActive(userId, patch);
|
||||||
const target = await this.repository.findById(userId);
|
} catch (error) {
|
||||||
const targetIsActiveAdmin = target?.role === "admin" && target.active;
|
if (error instanceof LastActiveAdminStoreError) throw new LastAdminError();
|
||||||
if (targetIsActiveAdmin) {
|
throw error;
|
||||||
const activeAdmins = (await this.repository.list()).filter(
|
|
||||||
(user) => user.role === "admin" && user.active
|
|
||||||
);
|
|
||||||
if (activeAdmins.length <= 1) throw new LastAdminError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return this.repository.updateRoleAndActive(userId, patch);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin-triggered reset with a new start password; forces a change on next login. */
|
/** Admin-triggered reset with a new start password; forces a change on next login. */
|
||||||
async resetPassword(userId: string, newPassword: string) {
|
async resetPassword(userId: string, newPassword: string) {
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await this.passwords.hash(newPassword);
|
||||||
return this.repository.setPassword(userId, passwordHash, true);
|
return this.repository.setPassword(userId, passwordHash, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,15 +142,15 @@ export class UserService {
|
||||||
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
|
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
|
||||||
const user = await this.repository.findById(userId);
|
const user = await this.repository.findById(userId);
|
||||||
if (!user) throw new WrongPasswordError();
|
if (!user) throw new WrongPasswordError();
|
||||||
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
|
const currentMatches = await this.passwords.verify(currentPassword, user.passwordHash);
|
||||||
if (!currentMatches) throw new WrongPasswordError();
|
if (!currentMatches) throw new WrongPasswordError();
|
||||||
if (newPassword === currentPassword) throw new PasswordUnchangedError();
|
if (newPassword === currentPassword) throw new PasswordUnchangedError();
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await this.passwords.hash(newPassword);
|
||||||
return this.repository.setPassword(userId, passwordHash, false);
|
return this.repository.setPassword(userId, passwordHash, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDummyHash(): Promise<string> {
|
private getDummyHash(): Promise<string> {
|
||||||
this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login");
|
this.dummyHash ??= this.passwords.hash("dummy-password-for-constant-time-login");
|
||||||
return this.dummyHash;
|
return this.dummyHash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { listProjects } from "../utils/api";
|
||||||
import type { ProjectDto } from "../types";
|
import type { ProjectDto } from "../types";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { useAuth } from "./AuthProvider";
|
import { useAuth } from "./AuthProvider";
|
||||||
|
import { getConfigEntry } from "../utils/config-access";
|
||||||
|
|
||||||
// Anchors on the project overview page (/projects/[id]).
|
// Anchors on the project overview page (/projects/[id]).
|
||||||
const PROJECT_SECTIONS = [
|
const PROJECT_SECTIONS = [
|
||||||
|
|
@ -32,6 +33,7 @@ interface SidebarProps {
|
||||||
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const configEntry = getConfigEntry(isAdmin);
|
||||||
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
||||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||||
const projectSubPath = projectMatch?.[2] ?? "";
|
const projectSubPath = projectMatch?.[2] ?? "";
|
||||||
|
|
@ -181,18 +183,16 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
{isAdmin && (
|
<nav className="sidebar-nav sidebar-nav-bottom">
|
||||||
<nav className="sidebar-nav sidebar-nav-bottom">
|
<Link
|
||||||
<Link
|
href={configEntry.href}
|
||||||
href="/config"
|
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
|
||||||
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
|
title={configEntry.label}
|
||||||
title="Config"
|
>
|
||||||
>
|
<span className="sidebar-icon">⚙</span>
|
||||||
<span className="sidebar-icon">⚙</span>
|
<span className="sidebar-label">{configEntry.label}</span>
|
||||||
<span className="sidebar-label">Config</span>
|
</Link>
|
||||||
</Link>
|
</nav>
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
{user && !isCollapsed && (
|
{user && !isCollapsed && (
|
||||||
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>
|
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import type {
|
||||||
CableSizingInput,
|
CableSizingInput,
|
||||||
CableSizingResult,
|
CableSizingResult,
|
||||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||||
|
import {
|
||||||
|
WRITE_REQUEST_HEADER_NAME,
|
||||||
|
WRITE_REQUEST_HEADER_VALUE,
|
||||||
|
} from "../../shared/constants/request-security";
|
||||||
|
|
||||||
export interface CalculateCableSizingResponse {
|
export interface CalculateCableSizingResponse {
|
||||||
result: CableSizingResult;
|
result: CableSizingResult;
|
||||||
|
|
@ -17,7 +21,10 @@ export async function calculateCableSizing(
|
||||||
): Promise<CalculateCableSizingResponse> {
|
): Promise<CalculateCableSizingResponse> {
|
||||||
const response = await fetch("/api/cable-sizing/calculate", {
|
const response = await fetch("/api/cable-sizing/calculate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
|
||||||
|
},
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import {
|
||||||
|
WRITE_REQUEST_HEADER_NAME,
|
||||||
|
WRITE_REQUEST_HEADER_VALUE,
|
||||||
|
} from "../../shared/constants/request-security";
|
||||||
import type {
|
import type {
|
||||||
BuildingDto,
|
BuildingDto,
|
||||||
CircuitListDto,
|
CircuitListDto,
|
||||||
|
|
@ -122,6 +126,7 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
import {
|
||||||
|
WRITE_REQUEST_HEADER_NAME,
|
||||||
|
WRITE_REQUEST_HEADER_VALUE,
|
||||||
|
} from "../../shared/constants/request-security";
|
||||||
|
|
||||||
// Small, self-contained fetch helper for the auth/user endpoints - kept
|
// Small, self-contained fetch helper for the auth/user endpoints - kept
|
||||||
// separate from the large domain api.ts so this feature doesn't have to
|
// separate from the large domain api.ts so this feature doesn't have to
|
||||||
// touch that file's shared `request()` internals.
|
// touch that file's shared `request()` internals.
|
||||||
|
|
@ -39,7 +44,11 @@ export class AuthApiError extends Error {
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
25
src/frontend/utils/config-access.ts
Normal file
25
src/frontend/utils/config-access.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
export interface ConfigNavigationEntry {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_ENTRY = { href: "/config/konto", label: "Konto" } as const;
|
||||||
|
const USERS_ENTRY = { href: "/config/benutzer", label: "Benutzer" } as const;
|
||||||
|
|
||||||
|
export function getConfigTabs(isAdmin: boolean): ConfigNavigationEntry[] {
|
||||||
|
return isAdmin ? [USERS_ENTRY, ACCOUNT_ENTRY] : [ACCOUNT_ENTRY];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigEntry(isAdmin: boolean): ConfigNavigationEntry {
|
||||||
|
return isAdmin ? { href: USERS_ENTRY.href, label: "Config" } : ACCOUNT_ENTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigRedirect(pathname: string | null, isAdmin: boolean): string | null {
|
||||||
|
if (pathname === "/config") {
|
||||||
|
return isAdmin ? USERS_ENTRY.href : ACCOUNT_ENTRY.href;
|
||||||
|
}
|
||||||
|
if (!isAdmin && pathname?.startsWith(USERS_ENTRY.href)) {
|
||||||
|
return ACCOUNT_ENTRY.href;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,12 @@ import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||||
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
|
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
|
||||||
import { readCookie } from "./cookie.js";
|
import { readCookie } from "./cookie.js";
|
||||||
import type { SessionStore } from "./sessions.js";
|
import type { SessionStore } from "./sessions.js";
|
||||||
import type { UserRepository } from "../../db/repositories/user.repository.js";
|
import type { UserStore } from "../../domain/ports/user.store.js";
|
||||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||||
|
import {
|
||||||
|
WRITE_REQUEST_HEADER_NAME,
|
||||||
|
WRITE_REQUEST_HEADER_VALUE,
|
||||||
|
} from "../../shared/constants/request-security.js";
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -30,7 +34,7 @@ export function sessionIdFromRequest(req: {
|
||||||
export async function identityFromRequest(
|
export async function identityFromRequest(
|
||||||
req: { headers: { cookie?: string | undefined } },
|
req: { headers: { cookie?: string | undefined } },
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
users: UserRepository,
|
users: Pick<UserStore, "findById">,
|
||||||
slideExpiry = true
|
slideExpiry = true
|
||||||
): Promise<AuthUser | undefined> {
|
): Promise<AuthUser | undefined> {
|
||||||
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
|
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
|
||||||
|
|
@ -74,10 +78,11 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
* CSRF defense for cookie-authenticated writes.
|
* CSRF defense for cookie-authenticated writes.
|
||||||
*
|
*
|
||||||
* SameSite=Lax alone is not enough here: every other service on the same
|
* SameSite=Lax alone is not enough here: every other service on the same
|
||||||
* host (other ports of the same IP) counts as "same-site" and would get the
|
* host (other ports of the same IP) counts as "same-site" and may receive the
|
||||||
* cookie attached. Browsers send Sec-Fetch-Site on every request, so a write
|
* cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN.
|
||||||
* is only accepted from the app's own origin. Clients without the header
|
* Every write therefore needs an application header that HTML forms cannot
|
||||||
* (curl, scripts) carry no ambient browser cookie and are not a CSRF vector.
|
* set; a cross-origin fetch would require a CORS preflight, which the API does
|
||||||
|
* not permit. Sec-Fetch-Site remains an additional browser-side check.
|
||||||
*/
|
*/
|
||||||
export function requireSameOriginForWrites(): RequestHandler {
|
export function requireSameOriginForWrites(): RequestHandler {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|
@ -85,6 +90,10 @@ export function requireSameOriginForWrites(): RequestHandler {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) {
|
||||||
|
res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fetchSite = req.headers["sec-fetch-site"];
|
const fetchSite = req.headers["sec-fetch-site"];
|
||||||
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
|
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
|
||||||
next();
|
next();
|
||||||
|
|
@ -98,7 +107,7 @@ export function requireSameOriginForWrites(): RequestHandler {
|
||||||
export function createAuthGuard(
|
export function createAuthGuard(
|
||||||
authConfig: AuthConfig,
|
authConfig: AuthConfig,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
users: UserRepository,
|
users: Pick<UserStore, "findById">,
|
||||||
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
||||||
): RequestHandler {
|
): RequestHandler {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ const SCRYPT_N = 16384;
|
||||||
const SCRYPT_R = 8;
|
const SCRYPT_R = 8;
|
||||||
const SCRYPT_P = 1;
|
const SCRYPT_P = 1;
|
||||||
const KEY_LENGTH = 64;
|
const KEY_LENGTH = 64;
|
||||||
|
const SALT_LENGTH = 16;
|
||||||
|
const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024;
|
||||||
|
const MIN_SCRYPT_N = 1 << 14;
|
||||||
|
const MAX_SCRYPT_N = 1 << 16;
|
||||||
|
const MAX_SCRYPT_R = 16;
|
||||||
|
const MAX_SCRYPT_P = 8;
|
||||||
|
|
||||||
// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only
|
// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only
|
||||||
// resolves to scrypt's 3-argument overload, so the 4-argument form used for
|
// resolves to scrypt's 3-argument overload, so the 4-argument form used for
|
||||||
|
|
@ -30,8 +36,13 @@ function scryptAsync(
|
||||||
// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the
|
// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the
|
||||||
// hash so they can be tuned later without invalidating existing rows.
|
// hash so they can be tuned later without invalidating existing rows.
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
const salt = randomBytes(16);
|
const salt = randomBytes(SALT_LENGTH);
|
||||||
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
|
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, {
|
||||||
|
N: SCRYPT_N,
|
||||||
|
r: SCRYPT_R,
|
||||||
|
p: SCRYPT_P,
|
||||||
|
maxmem: MAX_SCRYPT_MEMORY,
|
||||||
|
});
|
||||||
return [
|
return [
|
||||||
"scrypt",
|
"scrypt",
|
||||||
SCRYPT_N,
|
SCRYPT_N,
|
||||||
|
|
@ -52,12 +63,33 @@ export async function verifyPassword(
|
||||||
const n = Number(nRaw);
|
const n = Number(nRaw);
|
||||||
const r = Number(rRaw);
|
const r = Number(rRaw);
|
||||||
const p = Number(pRaw);
|
const p = Number(pRaw);
|
||||||
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
|
if (
|
||||||
|
!Number.isSafeInteger(n) ||
|
||||||
|
n < MIN_SCRYPT_N ||
|
||||||
|
n > MAX_SCRYPT_N ||
|
||||||
|
(n & (n - 1)) !== 0 ||
|
||||||
|
!Number.isSafeInteger(r) ||
|
||||||
|
r < 1 ||
|
||||||
|
r > MAX_SCRYPT_R ||
|
||||||
|
!Number.isSafeInteger(p) ||
|
||||||
|
p < 1 ||
|
||||||
|
p > MAX_SCRYPT_P ||
|
||||||
|
!/^[0-9a-f]{32}$/i.test(saltHex) ||
|
||||||
|
!/^[0-9a-f]{128}$/i.test(hashHex)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const salt = Buffer.from(saltHex, "hex");
|
const salt = Buffer.from(saltHex, "hex");
|
||||||
const expected = Buffer.from(hashHex, "hex");
|
const expected = Buffer.from(hashHex, "hex");
|
||||||
const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p });
|
try {
|
||||||
if (derivedKey.length !== expected.length) return false;
|
const derivedKey = await scryptAsync(password, salt, expected.length, {
|
||||||
return timingSafeEqual(derivedKey, expected);
|
N: n,
|
||||||
|
r,
|
||||||
|
p,
|
||||||
|
maxmem: MAX_SCRYPT_MEMORY,
|
||||||
|
});
|
||||||
|
return timingSafeEqual(derivedKey, expected);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,14 @@ import { LoginThrottle } from "../auth/login-throttle.js";
|
||||||
import { SessionStore } from "../auth/sessions.js";
|
import { SessionStore } from "../auth/sessions.js";
|
||||||
import { UserService } from "../../domain/services/user.service.js";
|
import { UserService } from "../../domain/services/user.service.js";
|
||||||
import { userRepository } from "./application-repositories.js";
|
import { userRepository } from "./application-repositories.js";
|
||||||
|
import { hashPassword, verifyPassword } from "../auth/password.js";
|
||||||
|
|
||||||
export const authConfig = readAuthConfig();
|
export const authConfig = readAuthConfig();
|
||||||
export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs);
|
export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs);
|
||||||
sessionStore.startSweeping();
|
sessionStore.startSweeping();
|
||||||
export const loginThrottle = new LoginThrottle();
|
export const loginThrottle = new LoginThrottle();
|
||||||
export const userService = new UserService(userRepository, authConfig);
|
export const userService = new UserService(
|
||||||
|
userRepository,
|
||||||
|
{ bootstrap: authConfig.bootstrap },
|
||||||
|
{ hash: hashPassword, verify: verifyPassword }
|
||||||
|
);
|
||||||
|
|
|
||||||
2
src/shared/constants/request-security.ts
Normal file
2
src/shared/constants/request-security.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request";
|
||||||
|
export const WRITE_REQUEST_HEADER_VALUE = "1";
|
||||||
|
|
@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j
|
||||||
import type { AuthConfig } from "../src/server/auth/auth-config.js";
|
import type { AuthConfig } from "../src/server/auth/auth-config.js";
|
||||||
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
|
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
|
||||||
import { loginSchema } from "../src/shared/validation/auth.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;
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
|
@ -34,7 +38,14 @@ function createUserService() {
|
||||||
cookieSecure: false,
|
cookieSecure: false,
|
||||||
bootstrap: { username: "admin", password: "bootstrap-password-123" },
|
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", () => {
|
describe("password hashing", () => {
|
||||||
|
|
@ -52,6 +63,17 @@ describe("password hashing", () => {
|
||||||
it("rejects malformed stored hashes instead of throwing", async () => {
|
it("rejects malformed stored hashes instead of throwing", async () => {
|
||||||
assert.equal(await verifyPassword("x", "not-a-hash"), false);
|
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$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", () => {
|
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 status: number | undefined;
|
||||||
let passed = false;
|
let passed = false;
|
||||||
const req = {
|
const req = {
|
||||||
method,
|
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;
|
} as unknown as Request;
|
||||||
const res = {
|
const res = {
|
||||||
status(code: number) {
|
status(code: number) {
|
||||||
|
|
@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
it("lets reads and same-origin writes through", () => {
|
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", "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("POST", "cross-site"), { passed: false, status: 403 });
|
||||||
assert.deepEqual(run("PATCH", "same-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 });
|
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);
|
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 () => {
|
it("requires the current password and a different new one", async () => {
|
||||||
const { service } = createUserService();
|
const { service } = createUserService();
|
||||||
const user = await service.createUser({
|
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