Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen
This commit is contained in:
commit
1a30233269
63 files changed
+12138
No files matched your search
@@ -0,0 +1,568 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// These records exist only in Playwright network mocks. They are never seeded
|
||||
// into an application database, filesystem store, or production response.
|
||||
const capabilities = {
|
||||
readOrders: true,
|
||||
readCustomers: true,
|
||||
readCouriers: true,
|
||||
readInvoices: false,
|
||||
writeOrders: false,
|
||||
writeCustomers: false,
|
||||
writeCouriers: false,
|
||||
writeInvoices: false,
|
||||
completeStops: false,
|
||||
exportOrders: true,
|
||||
};
|
||||
const user = {
|
||||
username: "browser-test",
|
||||
roles: ["VIEWER"],
|
||||
headquartersId: 1,
|
||||
capabilities,
|
||||
};
|
||||
const system = {
|
||||
connected: true,
|
||||
database: "legacy-mysql",
|
||||
readOnly: true,
|
||||
schemaChanges: false,
|
||||
message: "Testfixture: bestehende Datenbank verbunden",
|
||||
legacyUrl: "https://legacy.example.test/votian",
|
||||
maxResults: 500,
|
||||
capabilities,
|
||||
unavailableModules: [
|
||||
"Rechnungserstellung und Zahlungsstatus",
|
||||
"Historie aus separater Log-Datenbank",
|
||||
],
|
||||
};
|
||||
const customers = [
|
||||
{
|
||||
id: 11,
|
||||
number: "C-TEST",
|
||||
name: "Testkunde Nordlicht",
|
||||
street: "Teststraße 1",
|
||||
postalCode: "20095",
|
||||
city: "Hamburg",
|
||||
contact: "Testkontakt",
|
||||
phone: "",
|
||||
email: "",
|
||||
paymentDays: 14,
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
const couriers = [
|
||||
{
|
||||
id: 22,
|
||||
number: "K-TEST",
|
||||
name: "Testkurier Hafen",
|
||||
phone: "",
|
||||
vehicle: "Transporter",
|
||||
plate: "HH-TEST",
|
||||
status: "AVAILABLE",
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const orders = [
|
||||
{
|
||||
id: 880001,
|
||||
number: "880001",
|
||||
version: 2,
|
||||
status: "DISPOSITION",
|
||||
legacyStatus: 9,
|
||||
legacyStatusLabel: "Disposition",
|
||||
customerId: 11,
|
||||
courierId: null,
|
||||
service: "Direktfahrt",
|
||||
vehicle: "Transporter",
|
||||
pickupAt: `${today}T09:00:00`,
|
||||
deliveryDeadline: null,
|
||||
reference: "TEST-REFERENZ",
|
||||
costCenter: "Testkostenstelle",
|
||||
notes: "Nur Browser-Testfixture",
|
||||
packages: null,
|
||||
weight: 12.5,
|
||||
distanceKm: null,
|
||||
netPrice: 42.5,
|
||||
courierPrice: 25,
|
||||
vatRate: 19,
|
||||
recipientName: "",
|
||||
deliveredAt: null,
|
||||
createdAt: `${today}T08:00:00`,
|
||||
updatedAt: `${today}T08:10:00`,
|
||||
cancelMode: null,
|
||||
invoiceId: null,
|
||||
stops: [
|
||||
{
|
||||
id: 1,
|
||||
sequence: 1,
|
||||
type: "PICKUP",
|
||||
direction: 1,
|
||||
name: "Abholung Testlager",
|
||||
street: "Teststraße 1",
|
||||
postalCode: "20095",
|
||||
city: "Hamburg",
|
||||
contact: "",
|
||||
phone: "",
|
||||
note: "",
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sequence: 2,
|
||||
type: "DELIVERY",
|
||||
direction: 2,
|
||||
name: "Zustellung Testbüro",
|
||||
street: "Hafenweg 2",
|
||||
postalCode: "20457",
|
||||
city: "Hamburg",
|
||||
contact: "",
|
||||
phone: "",
|
||||
note: "",
|
||||
completedAt: null,
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
},
|
||||
];
|
||||
|
||||
const loginPassword = "browser-only fixture +%&=$";
|
||||
|
||||
async function mockApi(
|
||||
page: Page,
|
||||
overrides: Record<string, unknown> = {},
|
||||
options: { authenticated?: boolean } = {},
|
||||
) {
|
||||
// Model the session on the server, independently of browser storage. A page
|
||||
// reload preserves it; logout and expiry invalidate it for every endpoint.
|
||||
let authenticated = options.authenticated ?? true;
|
||||
let csrfVersion = 0;
|
||||
const csrfToken = () => `browser-csrf-${csrfVersion}`;
|
||||
const currentUser = overrides["/me"] ?? user;
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace("/api", "");
|
||||
const respond = (status: number, body: unknown) =>
|
||||
route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// The application must leave the Authorization header available to the
|
||||
// reverse proxy. These tests run without proxy credentials.
|
||||
expect(request.headers().authorization).toBeUndefined();
|
||||
if (path === "/csrf" && request.method() === "GET")
|
||||
return respond(200, {
|
||||
token: csrfToken(),
|
||||
headerName: "X-CSRF-TOKEN",
|
||||
parameterName: "_csrf",
|
||||
});
|
||||
if (request.method() !== "GET") {
|
||||
if (request.headers()["x-csrf-token"] !== csrfToken())
|
||||
return respond(403, { message: "Invalid CSRF token", code: "CSRF_INVALID" });
|
||||
if (path === "/login" && request.method() === "POST") {
|
||||
expect(request.headers()["content-type"]).toContain(
|
||||
"application/x-www-form-urlencoded",
|
||||
);
|
||||
const credentials = new URLSearchParams(request.postData() ?? "");
|
||||
if (
|
||||
credentials.get("username") !== user.username ||
|
||||
credentials.get("password") !== loginPassword
|
||||
)
|
||||
return respond(401, {
|
||||
message: "Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.",
|
||||
});
|
||||
authenticated = true;
|
||||
csrfVersion += 1;
|
||||
return respond(200, currentUser);
|
||||
}
|
||||
if (path === "/logout" && request.method() === "POST") {
|
||||
authenticated = false;
|
||||
csrfVersion += 1;
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
throw new Error(`Unexpected mutation: ${request.method()} ${path}`);
|
||||
}
|
||||
if (!authenticated && path !== "/system")
|
||||
return respond(401, { message: "Anmeldung erforderlich." });
|
||||
let body: unknown = {
|
||||
"/me": currentUser,
|
||||
"/system": system,
|
||||
"/customers": customers,
|
||||
"/couriers": couriers,
|
||||
"/orders": orders,
|
||||
"/orders/880001": orders[0],
|
||||
...overrides,
|
||||
}[path];
|
||||
if (path === "/orders" && url.searchParams.get("q")) {
|
||||
const q = url.searchParams.get("q")!.toLowerCase();
|
||||
body = orders.filter((o) => JSON.stringify(o).toLowerCase().includes(q));
|
||||
}
|
||||
if (body === undefined)
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Unknown test endpoint: " + path }),
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "X-Total-Count": "1", "X-Result-Limit": "500" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
return {
|
||||
rotateCsrf: () => {
|
||||
csrfVersion += 1;
|
||||
},
|
||||
expire: () => {
|
||||
authenticated = false;
|
||||
csrfVersion += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function expectNoStoredCredentials(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
session: Object.entries(sessionStorage),
|
||||
local: Object.entries(localStorage),
|
||||
})),
|
||||
).toEqual({ session: [], local: [] });
|
||||
}
|
||||
|
||||
test("bundled application is available; real API fails closed without a database", async ({
|
||||
request,
|
||||
}) => {
|
||||
expect((await request.get("/api/health")).status()).toBe(200);
|
||||
const systemResponse = await request.get("/api/system");
|
||||
expect(systemResponse.status()).toBe(200);
|
||||
const state = await systemResponse.json();
|
||||
expect(state.connected).toBe(false);
|
||||
expect(state.schemaChanges).toBe(false);
|
||||
expect(state.readOnly).toBe(true);
|
||||
expect((await request.get("/api/orders")).status()).toBe(401);
|
||||
expect((await request.post("/api/orders", { data: {} })).status()).toBe(403);
|
||||
const csrfResponse = await request.get("/api/csrf");
|
||||
expect(csrfResponse.status()).toBe(200);
|
||||
const csrf = await csrfResponse.json();
|
||||
expect(
|
||||
(
|
||||
await request.post("/api/orders", {
|
||||
data: {},
|
||||
headers: { [csrf.headerName]: csrf.token },
|
||||
})
|
||||
).status(),
|
||||
).toBe(401);
|
||||
expect((await request.get("/stadtbote-logo.png")).status()).toBe(200);
|
||||
});
|
||||
|
||||
test("real session cookie is HttpOnly and honors HTTPS forwarded by the proxy", async ({
|
||||
playwright,
|
||||
baseURL,
|
||||
}) => {
|
||||
for (const secure of [false, true]) {
|
||||
const context = await playwright.request.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: secure ? { "X-Forwarded-Proto": "https" } : {},
|
||||
});
|
||||
try {
|
||||
const response = await context.get("/api/csrf");
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
token: expect.any(String),
|
||||
headerName: "X-CSRF-TOKEN",
|
||||
});
|
||||
const sessionCookie = (await context.storageState()).cookies.find(
|
||||
(cookie) => cookie.name === "VOTIANSESSION",
|
||||
);
|
||||
expect(sessionCookie).toMatchObject({
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure,
|
||||
path: "/",
|
||||
});
|
||||
} finally {
|
||||
await context.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("real backend ignores proxy Basic authentication and returns its own JSON 401", async ({
|
||||
request,
|
||||
}) => {
|
||||
const headers = {
|
||||
Authorization: `Basic ${Buffer.from("proxy-fixture:unused").toString("base64")}`,
|
||||
};
|
||||
expect((await request.get("/api/health", { headers })).status()).toBe(200);
|
||||
const response = await request.get("/api/me", { headers });
|
||||
expect(response.status()).toBe(401);
|
||||
expect(response.headers()["content-type"]).toContain("application/json");
|
||||
expect(response.headers()["www-authenticate"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("offline login reports connection issue and exposes no fake work data", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByText(
|
||||
/nicht erreichbar|keine.*verbindung|verbindung.*fehlgeschlagen/i,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
expect((await page.request.get("/api/csrf")).status()).toBe(200);
|
||||
expect(
|
||||
(await page.context().cookies()).find((cookie) => cookie.name === "VOTIANSESSION"),
|
||||
).toMatchObject({ httpOnly: true });
|
||||
expect(await page.evaluate(() => document.cookie)).not.toContain("VOTIANSESSION");
|
||||
});
|
||||
|
||||
test("login sends form credentials with CSRF and keeps them out of browser storage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page, {}, { authenticated: false });
|
||||
const initialSessionCheck = page.waitForResponse("**/api/me");
|
||||
await page.goto("/");
|
||||
expect((await initialSessionCheck).status()).toBe(401);
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await page.getByLabel("VoTiAn Benutzerkonto").fill(user.username);
|
||||
await page.getByLabel("Passwort", { exact: true }).fill(loginPassword);
|
||||
const loginRequest = page.waitForRequest("**/api/login");
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
const submitted = await loginRequest;
|
||||
expect(submitted.method()).toBe("POST");
|
||||
expect(new URLSearchParams(submitted.postData() ?? "").get("username")).toBe(
|
||||
user.username,
|
||||
);
|
||||
expect(new URLSearchParams(submitted.postData() ?? "").get("password")).toBe(
|
||||
loginPassword,
|
||||
);
|
||||
expect(submitted.headers()["x-csrf-token"]).toBe("browser-csrf-0");
|
||||
expect(submitted.headers().authorization).toBeUndefined();
|
||||
expect(new URL(submitted.url()).search).toBe("");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await expectNoStoredCredentials(page);
|
||||
// Login rotates the server CSRF token. Logout must use the new token.
|
||||
const logoutRequest = page.waitForRequest("**/api/logout");
|
||||
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||
expect((await logoutRequest).headers()["x-csrf-token"]).toBe("browser-csrf-1");
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await expectNoStoredCredentials(page);
|
||||
});
|
||||
|
||||
test("incorrect login remains anonymous without retaining credentials", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page, {}, { authenticated: false });
|
||||
await page.goto("/");
|
||||
await page.getByLabel("VoTiAn Benutzerkonto").fill(user.username);
|
||||
await page.getByLabel("Passwort", { exact: true }).fill("incorrect fixture");
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Anmeldung fehlgeschlagen");
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
await expectNoStoredCredentials(page);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a CSRF token changed by another session is refreshed before retrying logout", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await mockApi(page, {}, { authenticated: false });
|
||||
await page.goto("/");
|
||||
await page.getByLabel("VoTiAn Benutzerkonto").fill(user.username);
|
||||
await page.getByLabel("Passwort", { exact: true }).fill(loginPassword);
|
||||
const refreshedToken = page.waitForResponse(async (response) =>
|
||||
new URL(response.url()).pathname === "/api/csrf" &&
|
||||
(await response.json()).token === "browser-csrf-1",
|
||||
);
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await refreshedToken;
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
session.rotateCsrf();
|
||||
const logoutTokens: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/logout")
|
||||
logoutTokens.push(request.headers()["x-csrf-token"]);
|
||||
});
|
||||
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
expect(logoutTokens).toEqual(["browser-csrf-1", "browser-csrf-2"]);
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a non-CSRF logout failure is shown once and leaves the session active", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page);
|
||||
let logoutAttempts = 0;
|
||||
await page.route("**/api/logout", async (route) => {
|
||||
logoutAttempts += 1;
|
||||
return route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Abmeldung nicht erlaubt." }),
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Abmeldung fehlgeschlagen");
|
||||
expect(logoutAttempts).toBe(1);
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("existing server session is restored on load and reload without browser storage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page);
|
||||
const initialSessionCheck = page.waitForResponse("**/api/me");
|
||||
await page.goto("/");
|
||||
expect((await initialSessionCheck).status()).toBe(200);
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await expectNoStoredCredentials(page);
|
||||
const restoredSessionCheck = page.waitForResponse("**/api/me");
|
||||
await page.reload();
|
||||
expect((await restoredSessionCheck).status()).toBe(200);
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await expectNoStoredCredentials(page);
|
||||
});
|
||||
|
||||
test("existing account opens order list, details and module navigation with read-only capabilities", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await mockApi(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await expect(page.getByText("Testkunde Nordlicht").first()).toBeVisible();
|
||||
await page.getByText("880001").first().click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Abholung Testlager").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
|
||||
for (const name of [
|
||||
"Disposition",
|
||||
"Touren",
|
||||
"Kunden",
|
||||
"Kuriere",
|
||||
"Abrechnung",
|
||||
"Auswertung",
|
||||
]) {
|
||||
await page.getByRole("button", { name, exact: true }).click();
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
await page.screenshot({
|
||||
path: "work/test-results/auswertung-testfixture.png",
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("missing module rights do not trigger forbidden API calls", async ({
|
||||
page,
|
||||
}) => {
|
||||
const limited = {
|
||||
...capabilities,
|
||||
readCustomers: false,
|
||||
readCouriers: false,
|
||||
};
|
||||
const requested: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/"))
|
||||
requested.push(new URL(request.url()).pathname);
|
||||
});
|
||||
await mockApi(page, {
|
||||
"/me": { ...user, capabilities: limited },
|
||||
"/system": { ...system, capabilities: limited },
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
expect(requested).not.toContain("/api/customers");
|
||||
expect(requested).not.toContain("/api/couriers");
|
||||
});
|
||||
|
||||
test("search sends a server query instead of searching only a limited local list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === "/api/orders" &&
|
||||
new URL(request.url()).searchParams.get("q") === "TEST-REFERENZ",
|
||||
);
|
||||
await page
|
||||
.getByRole("textbox", { name: "Aufträge suchen" })
|
||||
.fill("TEST-REFERENZ");
|
||||
await request;
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("nullable legacy fields do not break order lists or reports", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await mockApi(page, {
|
||||
"/orders": [
|
||||
{
|
||||
...orders[0],
|
||||
pickupAt: null,
|
||||
netPrice: null,
|
||||
courierPrice: null,
|
||||
weight: null,
|
||||
vatRate: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Auswertung", exact: true }).click();
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("logout invalidates the server session and removes previous account data", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
const logoutRequest = page.waitForRequest("**/api/logout");
|
||||
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||
const submitted = await logoutRequest;
|
||||
expect(submitted.method()).toBe("POST");
|
||||
expect(submitted.headers()["x-csrf-token"]).toBe("browser-csrf-0");
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
await expect(page.getByText("Testkunde Nordlicht")).toHaveCount(0);
|
||||
await expectNoStoredCredentials(page);
|
||||
const sessionCheck = page.waitForResponse("**/api/me");
|
||||
await page.reload();
|
||||
expect((await sessionCheck).status()).toBe(401);
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("expired session clears work data and requests a fresh login", async ({ page }) => {
|
||||
const session = await mockApi(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("880001").first()).toBeVisible();
|
||||
await expect(page.getByText("Testkunde Nordlicht").first()).toBeVisible();
|
||||
session.expire();
|
||||
await page.getByRole("button", { name: "Aktualisieren", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||
await expect(page.getByText(/Sitzung ist abgelaufen/)).toBeVisible();
|
||||
await expect(page.getByText("880001")).toHaveCount(0);
|
||||
await expect(page.getByText("Testkunde Nordlicht")).toHaveCount(0);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expectNoStoredCredentials(page);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts/run-backend.py"
|
||||
SPEC = importlib.util.spec_from_file_location("backend_config", MODULE_PATH)
|
||||
CONFIG = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(CONFIG)
|
||||
|
||||
|
||||
class BackendConfigurationTest(unittest.TestCase):
|
||||
def test_env_file_works_without_php_or_connection_json(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / ".env").write_text("DB_URL=jdbc:mariadb://test.invalid/db\nDB_USER=fixture\nDB_PASSWORD=literal$#=secret\\end\n")
|
||||
result = CONFIG.database_environment(root, {})
|
||||
self.assertEqual(result["DB_PASSWORD"], "literal$#=secret\\end")
|
||||
self.assertEqual(result["DB_USER"], "fixture")
|
||||
|
||||
def test_process_environment_overrides_env_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / ".env").write_text("DB_URL=jdbc:mariadb://test.invalid/db\nDB_USER=fixture\nDB_PASSWORD=local\n")
|
||||
result = CONFIG.database_environment(root, {"DB_USER": "override", "DB_PASSWORD": ""})
|
||||
self.assertEqual(result["DB_USER"], "override")
|
||||
self.assertEqual(result["DB_PASSWORD"], "")
|
||||
|
||||
def test_explicit_environment_needs_no_file(self):
|
||||
expected = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "secret"}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
self.assertEqual(CONFIG.database_environment(Path(directory), expected), expected)
|
||||
|
||||
def test_missing_credentials_do_not_fall_back_to_legacy_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaises(ValueError):
|
||||
CONFIG.database_environment(Path(directory), {"DB_URL": "jdbc:mariadb://test.invalid/db"})
|
||||
|
||||
def test_jar_version_is_not_hardcoded_and_original_is_ignored(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "backend/target"
|
||||
target.mkdir(parents=True)
|
||||
jar = target / "votian-backend-0.1.jar"
|
||||
jar.touch()
|
||||
(target / "votian-backend-0.1.jar.original").touch()
|
||||
self.assertEqual(CONFIG.backend_jar(root), jar)
|
||||
(target / "votian-backend-1.0.0-SNAPSHOT.jar").touch()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
CONFIG.backend_jar(root)
|
||||
|
||||
def test_check_config_does_not_find_jar_or_launch_java(self):
|
||||
environment = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "secret"}
|
||||
with patch.object(CONFIG.sys, "argv", ["run-backend.py", "--check-config"]), \
|
||||
patch.object(CONFIG, "database_environment", return_value=environment), \
|
||||
patch.object(CONFIG, "backend_jar") as jar, \
|
||||
patch.object(CONFIG.os, "execvpe") as execute, contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(CONFIG.main(), 0)
|
||||
jar.assert_not_called()
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_launch_uses_env_port_and_keeps_secrets_out_of_arguments(self):
|
||||
for arguments, expected_port in (([], "18081"), (["--port", "18082"], "18082")):
|
||||
environment = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "secret", "PORT": "18081"}
|
||||
with self.subTest(arguments=arguments), \
|
||||
patch.object(CONFIG.sys, "argv", ["run-backend.py", *arguments]), \
|
||||
patch.object(CONFIG, "database_environment", return_value=environment), \
|
||||
patch.object(CONFIG, "backend_jar", return_value=Path("/fixture/backend.jar")), \
|
||||
patch.object(CONFIG.os, "chdir"), patch.object(CONFIG.os, "execvpe") as execute:
|
||||
self.assertEqual(CONFIG.main(), 0)
|
||||
command, actual_arguments, actual_environment = execute.call_args.args
|
||||
self.assertEqual(command, "java")
|
||||
self.assertEqual(actual_arguments, ["java", "-jar", "/fixture/backend.jar"])
|
||||
self.assertEqual(actual_environment["PORT"], expected_port)
|
||||
self.assertEqual(actual_environment["DB_PASSWORD"], "secret")
|
||||
|
||||
def test_configuration_error_never_prints_secret(self):
|
||||
output = io.StringIO()
|
||||
with patch.object(CONFIG.sys, "argv", ["run-backend.py", "--check-config"]), \
|
||||
patch.object(CONFIG, "database_environment", side_effect=ValueError("secret=do-not-expose")), \
|
||||
patch.object(CONFIG.os, "execvpe") as execute, contextlib.redirect_stderr(output):
|
||||
self.assertEqual(CONFIG.main(), 2)
|
||||
execute.assert_not_called()
|
||||
self.assertNotIn("do-not-expose", output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts/check-database.py"
|
||||
SPEC = importlib.util.spec_from_file_location("database_check", MODULE_PATH)
|
||||
CHECK = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(CHECK)
|
||||
|
||||
|
||||
class DatabaseCheckLauncherTest(unittest.TestCase):
|
||||
def test_delegates_to_java_with_credentials_only_in_environment(self):
|
||||
environment = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "test-secret"}
|
||||
with patch.object(CHECK.config, "backend_jar", return_value=Path("/fixture/backend.jar")), \
|
||||
patch.object(CHECK.config, "database_environment", return_value=environment), \
|
||||
patch.object(CHECK.os, "chdir"), patch.object(CHECK.os, "execvpe") as execute:
|
||||
self.assertEqual(CHECK.main(), 0)
|
||||
|
||||
command, arguments, actual_environment = execute.call_args.args
|
||||
self.assertEqual(command, "java")
|
||||
self.assertEqual(arguments[-1], "--database-check")
|
||||
self.assertNotIn("test-secret", " ".join(arguments))
|
||||
self.assertNotIn("test.invalid", " ".join(arguments))
|
||||
self.assertEqual(actual_environment, environment)
|
||||
|
||||
def test_missing_jar_never_loads_credentials_or_starts_java(self):
|
||||
output = io.StringIO()
|
||||
with patch.object(CHECK.config, "backend_jar", side_effect=FileNotFoundError), \
|
||||
patch.object(CHECK.config, "database_environment") as load, \
|
||||
patch.object(CHECK.os, "execvpe") as execute, contextlib.redirect_stdout(output):
|
||||
self.assertEqual(CHECK.main(), 2)
|
||||
load.assert_not_called()
|
||||
execute.assert_not_called()
|
||||
self.assertFalse(json.loads(output.getvalue())["connected"])
|
||||
|
||||
def test_configuration_error_is_sanitized_json(self):
|
||||
output = io.StringIO()
|
||||
with patch.object(CHECK.config, "backend_jar", return_value=Path("/fixture/backend.jar")), \
|
||||
patch.object(CHECK.config, "database_environment", side_effect=ValueError("secret=do-not-expose")), \
|
||||
patch.object(CHECK.os, "execvpe") as execute, contextlib.redirect_stdout(output):
|
||||
self.assertEqual(CHECK.main(), 2)
|
||||
execute.assert_not_called()
|
||||
self.assertNotIn("do-not-expose", output.getvalue())
|
||||
self.assertEqual(json.loads(output.getvalue())["writes"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,177 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "env_config.py"
|
||||
SPEC = importlib.util.spec_from_file_location("env_config", MODULE_PATH)
|
||||
env_config = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(env_config)
|
||||
|
||||
|
||||
class EnvironmentConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.path = Path(self.directory.name) / ".env"
|
||||
|
||||
def load(self, contents, current=None):
|
||||
self.path.write_text(contents, encoding="utf-8")
|
||||
return env_config.load_environment(self.path, current or {})
|
||||
|
||||
def test_current_environment_wins_including_empty_values(self):
|
||||
current = {"VALUE": "from-environment", "EMPTY": "", "EXTRA": "kept"}
|
||||
result = self.load("VALUE=from-file\nEMPTY=from-file\nFILE_ONLY=yes", current)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"VALUE": "from-environment", "EMPTY": "", "EXTRA": "kept", "FILE_ONLY": "yes"},
|
||||
)
|
||||
self.assertEqual(current, {"VALUE": "from-environment", "EMPTY": "", "EXTRA": "kept"})
|
||||
self.assertIsNot(result, current)
|
||||
|
||||
def test_missing_file_returns_a_copy_of_current_environment(self):
|
||||
current = {"VALUE": "present", "EMPTY": ""}
|
||||
result = env_config.load_environment(self.path, current)
|
||||
self.assertEqual(result, current)
|
||||
self.assertIsNot(result, current)
|
||||
|
||||
def test_blank_lines_comments_export_and_quotes(self):
|
||||
result = self.load(
|
||||
"\n \t\n # comment\n FIRST=one\n\texport SECOND='two words'\n"
|
||||
'THIRD="three words" \nEMPTY=\nEMPTY_QUOTED=\'\'\n'
|
||||
)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"FIRST": "one", "SECOND": "two words", "THIRD": "three words", "EMPTY": "", "EMPTY_QUOTED": ""},
|
||||
)
|
||||
|
||||
def test_special_characters_remain_literal(self):
|
||||
value = r"$NAME:${NAME}:#part=a=b:C:\folder\file\n"
|
||||
self.assertEqual(self.load(f"VALUE={value}\n"), {"VALUE": value})
|
||||
self.assertEqual(self.load(f"VALUE='{value}'\n"), {"VALUE": value})
|
||||
self.assertEqual(self.load(f'VALUE="{value}"\n'), {"VALUE": value})
|
||||
|
||||
def test_unquoted_whitespace_and_inline_hash_are_preserved(self):
|
||||
value = " value # still part of the value \t "
|
||||
self.assertEqual(self.load(f"VALUE={value}\n"), {"VALUE": value})
|
||||
|
||||
def test_commands_are_not_executed(self):
|
||||
marker = Path(self.directory.name) / "should-not-exist"
|
||||
value = f"$(touch {marker}) `touch {marker}` $HOME ${{HOME}}"
|
||||
self.assertEqual(self.load(f"VALUE={value}\n"), {"VALUE": value})
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_last_file_assignment_wins(self):
|
||||
self.assertEqual(self.load("VALUE=first\nVALUE=last\n"), {"VALUE": "last"})
|
||||
|
||||
def test_crlf_line_endings(self):
|
||||
self.path.write_bytes(b"FIRST=one\r\nSECOND=two\r\n")
|
||||
self.assertEqual(env_config.load_environment(self.path, {}), {"FIRST": "one", "SECOND": "two"})
|
||||
|
||||
def test_invalid_syntax_only_reports_line_number(self):
|
||||
for invalid in (
|
||||
"secret-without-an-assignment",
|
||||
"1INVALID=private-value",
|
||||
"INVALID-KEY=private-value",
|
||||
"INVALID KEY=private-value",
|
||||
"=private-value",
|
||||
"export VALUE",
|
||||
"VALUE='private-value",
|
||||
'VALUE="private-value',
|
||||
"VALUE='",
|
||||
'VALUE="',
|
||||
"VALUE='private-value' trailing-text",
|
||||
"VALUE=private\x00value",
|
||||
"# private\x00value",
|
||||
"VALUE=private\rvalue",
|
||||
):
|
||||
with self.subTest(invalid=invalid):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self.load(f"VALID=ok\n{invalid}\n")
|
||||
self.assertEqual(str(caught.exception), "Invalid .env syntax at line 2")
|
||||
|
||||
def test_invalid_encoding_only_reports_line_number(self):
|
||||
self.path.write_bytes(b"VALID=ok\nVALUE=private\xffvalue\n")
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
env_config.load_environment(self.path, {})
|
||||
self.assertEqual(str(caught.exception), "Invalid .env syntax at line 2")
|
||||
self.assertTrue(caught.exception.__suppress_context__)
|
||||
|
||||
def test_invalid_file_is_validated_even_when_current_has_the_key(self):
|
||||
with self.assertRaisesRegex(ValueError, r"^Invalid \.env syntax at line 1$"):
|
||||
self.load("VALUE='unterminated", {"VALUE": "current"})
|
||||
|
||||
|
||||
class EnvironmentCommandTests(unittest.TestCase):
|
||||
def test_command_executes_with_environment_and_literal_arguments(self):
|
||||
current = {"EXISTING": "present", "EMPTY": ""}
|
||||
environment = {**current, "FROM_FILE": "loaded"}
|
||||
command = ["./mvnw", "test", "literal $HOME ; $(command) `command`"]
|
||||
original_directory = Path.cwd()
|
||||
with (
|
||||
patch.dict(os.environ, current, clear=True),
|
||||
patch.object(env_config, "load_environment", return_value=environment) as load,
|
||||
patch.object(env_config.os, "execvpe") as execute,
|
||||
):
|
||||
self.assertEqual(env_config.main(command), 0)
|
||||
load.assert_called_once_with(MODULE_PATH.parents[1] / ".env", current)
|
||||
execute.assert_called_once_with(command[0], command, environment)
|
||||
self.assertEqual(Path.cwd(), original_directory)
|
||||
|
||||
def test_help_and_missing_command_do_not_load_or_execute(self):
|
||||
for arguments, status, stream in (([], 2, "stderr"), (["--help"], 0, "stdout"), (["-h"], 0, "stdout")):
|
||||
with (
|
||||
self.subTest(arguments=arguments),
|
||||
patch.object(env_config, "load_environment") as load,
|
||||
patch.object(env_config.os, "execvpe") as execute,
|
||||
patch.object(env_config.sys, stream, new_callable=io.StringIO) as output,
|
||||
):
|
||||
self.assertEqual(env_config.main(arguments), status)
|
||||
self.assertIn("COMMAND [ARGS...]", output.getvalue())
|
||||
load.assert_not_called()
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_invalid_file_reports_only_safe_line_number(self):
|
||||
with (
|
||||
patch.object(env_config, "load_environment", side_effect=ValueError("Invalid .env syntax at line 7")),
|
||||
patch.object(env_config.os, "execvpe") as execute,
|
||||
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||
):
|
||||
self.assertEqual(env_config.main(["command"]), 1)
|
||||
self.assertEqual(output.getvalue(), "Invalid .env syntax at line 7\n")
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_unexpected_value_error_is_sanitized(self):
|
||||
with (
|
||||
patch.object(env_config, "load_environment", side_effect=ValueError("private-value")),
|
||||
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||
):
|
||||
self.assertEqual(env_config.main(["command"]), 1)
|
||||
self.assertEqual(output.getvalue(), "Invalid .env syntax.\n")
|
||||
|
||||
def test_read_error_is_sanitized(self):
|
||||
with (
|
||||
patch.object(env_config, "load_environment", side_effect=OSError("private-value")),
|
||||
patch.object(env_config.os, "execvpe") as execute,
|
||||
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||
):
|
||||
self.assertEqual(env_config.main(["command"]), 1)
|
||||
self.assertEqual(output.getvalue(), "Unable to read .env.\n")
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_execution_error_is_sanitized(self):
|
||||
with (
|
||||
patch.object(env_config, "load_environment", return_value={}),
|
||||
patch.object(env_config.os, "execvpe", side_effect=OSError("private-value")),
|
||||
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||
):
|
||||
self.assertEqual(env_config.main(["private-command", "private-value"]), 1)
|
||||
self.assertEqual(output.getvalue(), "Unable to start command.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user