569 lines
19 KiB
TypeScript
569 lines
19 KiB
TypeScript
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);
|
|
});
|