Skip to content
Merged

Dev #126

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@ cp .env.example .env

The application and Compose deployment use the following variables:

| Variable | Required | Description |
| ----------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Yes outside the published image | Prisma SQLite connection URL. `.env.example` uses `file:./dev.db` for local development. Compose deliberately overrides it with `file:/app/data/database.db`; do not point a container at a database outside `/app/data`. |
| `NEXTAUTH_URL` | Yes | Exact public origin used for authentication callbacks and cookies, including the port (for example `http://192.168.10.145:3000`) or the HTTPS reverse-proxy URL. |
| `NEXTAUTH_SECRET` | Yes | Long, random, stable secret that signs sessions and derives the TOTP encryption key. Generate one with `openssl rand -base64 32`; changing it invalidates sessions and existing TOTP enrollment data. |
| `SMTP_HOST` | Required for contact sending | SMTP server hostname. The code uses separate host and port values; it does **not** read an SMTP URL variable. |
| `SMTP_PORT` | Required for contact sending | Numeric SMTP port. Port `465` enables implicit TLS; other ports use the transport defaults (commonly STARTTLS on `587`). |
| `SMTP_USER` | Required for contact sending | SMTP account and sender address. |
| `SMTP_PASSWORD` | Required for contact sending | Password or provider-issued credential for `SMTP_USER`. |
| `CONTACT_EMAIL` | Required for contact sending | Destination mailbox that receives messages submitted through `/contact`. |
| `ML_HELPER_IMAGE` | Compose only | Container image reference. It defaults to `ghcr.io/magicgg91/ml-helper:dev`; set `ghcr.io/magicgg91/ml-helper:latest` for the stable deployment channel or another fully qualified image tag for local testing. |
| Variable | Required | Description |
| ----------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Yes outside the published image | Prisma SQLite connection URL. `.env.example` uses `file:./dev.db` for local development. Compose deliberately overrides it with `file:/app/data/database.db`; do not point a container at a database outside `/app/data`. |
| `NEXTAUTH_URL` | Yes | Exact public origin used for authentication callbacks and cookies, including the port (for example `http://192.168.10.145:3000`) or the HTTPS reverse-proxy URL. |
| `NEXTAUTH_SECRET` | Yes | Long, random, stable secret that signs sessions and derives the TOTP encryption key. Generate one with `openssl rand -base64 32`; changing it invalidates sessions and existing TOTP enrollment data. |
| `SMTP_HOST` | Required for contact sending | SMTP server hostname. The code uses separate host and port values; it does **not** read an SMTP URL variable. |
| `SMTP_PORT` | Required for contact sending | Numeric SMTP port. Port `465` enables implicit TLS; other ports use the transport defaults (commonly STARTTLS on `587`). |
| `SMTP_USER` | Required for contact sending | SMTP account and sender address. |
| `SMTP_PASSWORD` | Required for contact sending | Password or provider-issued credential for `SMTP_USER`. |
| `CONTACT_EMAIL` | Required for contact sending | Destination mailbox that receives messages submitted through `/contact`. |
| `ML_HELPER_IMAGE` | Compose only | Container image reference. It defaults to `ghcr.io/magicgg91/ml-helper:dev`; set `ghcr.io/magicgg91/ml-helper:latest` for the stable deployment channel or another fully qualified image tag for local testing. |
| `TRACKING_ORIGIN` | No | Origin of the visit-tracking script configured in Admin → Configuration. Only needed when that script is served from another domain: its own requests are governed by the CSP's `connect-src`, which otherwise allows this site only. Compose sets it directly (no `.env` entry needed) and it can still be overridden from the environment. The script URL itself stays editable in the admin — this names the origin its measurements may be sent to. |

When any SMTP value is absent, the contact form reports that sending is not
configured instead of failing the rest of the site. Keep `.env` private; only
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ services:
SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
# Adresse qui reçoit les messages envoyés depuis /contact.
CONTACT_EMAIL: "${CONTACT_EMAIL:-}"
# Origine du script de suivi des visites, uniquement s'il est servi
# depuis un autre domaine que le site. L'URL du script se règle dans
# l'admin ; cette variable autorise ses envois de mesures dans le
# connect-src de la CSP. Ce n'est pas un secret — elle apparaît de toute
# façon dans l'en-tête CSP de chaque réponse — donc elle se pose ici
# directement, sans passer par .env. Vider la valeur revient à n'ouvrir
# aucun autre domaine.
TRACKING_ORIGIN: "${TRACKING_ORIGIN:-https://stats.domopi.eu}"
ports:
- "3000:3000"
volumes:
Expand Down
207 changes: 204 additions & 3 deletions e2e/phase-one.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { defaultLevelUpParameters } from "../src/lib/level-up";
import * as OTPAuth from "otpauth";

test.describe.configure({ mode: "serial" });
Expand Down Expand Up @@ -403,6 +404,58 @@ test("Progression is a Référentiels reference and keeps Silver unconfirmed", a
).toBeVisible();
});

// Bloc 98/A: the bug this bloc fixes, end to end and in the order it was
// reported — an admin fills a league in, saves, and the public reference is
// still telling players the league is unavailable. Availability now comes from
// the stored values, so saving is all it takes.
test("Bloc 98/A: a league becomes available publicly as soon as an admin fills it in", async ({
page,
}) => {
test.setTimeout(60_000);
const leagueGroup = page.getByRole("group", { name: "Ligue" });
const endpoint = "/api/admin/guides/references/level-up";

await page.goto("/referentiels/level-up");
await leagueGroup.getByRole("button", { name: "Argent" }).click();
await expect(page.getByRole("status")).toContainText("non encore confirmée");
await expect(page.getByRole("table")).toHaveCount(0);

await b90EnsureRoot(page);
await b90Login(page, B90_ROOT.username, B90_ROOT.password);
const filled = await page.request.put(endpoint, {
data: {
...defaultLevelUpParameters,
troops: {
...defaultLevelUpParameters.troops,
silver: { coefficient: 30, ratio: 1.24 },
},
},
});
expect(filled.status()).toBe(200);

await page.goto("/referentiels/level-up");
await leagueGroup.getByRole("button", { name: "Argent" }).click();
await expect(page.getByRole("table").first()).toBeVisible();
// The saved values are what the table is built from: level 2 is
// coefficient × ratio² = 30 × 1.24² = 46.
await expect(
page.getByRole("row").nth(2).getByRole("cell").nth(2),
).toHaveText("46");

// Putting the league back to blank must be savable too — the admin route
// used to reject any zero, so the reference could not be saved at all while
// a league was still unconfirmed (Bloc 98/A). This also restores the seeded
// state for the rest of the suite.
const blanked = await page.request.put(endpoint, {
data: defaultLevelUpParameters,
});
expect(blanked.status()).toBe(200);
await page.goto("/referentiels/level-up");
await leagueGroup.getByRole("button", { name: "Argent" }).click();
await expect(page.getByRole("status")).toContainText("Ligues disponibles :");
await expect(page.getByRole("table")).toHaveCount(0);
});

test("calculator pages only repeat names in their navigation tabs", async ({
page,
}) => {
Expand Down Expand Up @@ -667,9 +720,7 @@ test("Ranking converts position and percentage into league ranges", async ({
await rankingLeagueGroup.getByRole("button", { name: "Bronze" }).click();
// Bloc 92/A11y: the ranking placeholder no longer carries its own
// role="status" (it sits inside a permanent aria-live region); match its text.
await expect(
page.getByText(/à définir dans l’administration/),
).toBeVisible();
await expect(page.getByText(/à définir dans l’administration/)).toBeVisible();
});

test("Skills exposes gem distributions and exact templar costs", async ({
Expand Down Expand Up @@ -2220,6 +2271,156 @@ test("Bloc 90/A: Configuration tab restricted to admin/super_admin", async ({
await toolsContext.close();
});

// Bloc 100/A+B: the tracking script URL is set from the admin and loads on
// every page. The interesting half is the CSP: the policy is nonce-based with
// 'strict-dynamic' (src/proxy.ts), under which host allowlists are ignored —
// so a cross-origin script is authorised by carrying the request's nonce, and
// by nothing else. That is what makes an admin-editable URL possible at all.
test("Bloc 100/A+B: a tracking URL set in the admin loads everywhere, under the page's own nonce", async ({
page,
}) => {
test.setTimeout(60_000);
const endpoint = "/api/admin/config/tracking";
const trackingUrl = "https://stats.example.test/script.js";
const websiteId = "25931871-50b0-4123-a327-09f9c60cff18";

// Collect the CSP violations the browser itself reports, on every page.
await page.addInitScript(() => {
const violations: string[] = [];
(window as unknown as { cspViolations: string[] }).cspViolations =
violations;
document.addEventListener("securitypolicyviolation", (event) =>
violations.push(`${event.violatedDirective} ${event.blockedURI}`),
);
});

await b90EnsureRoot(page);
await b90Login(page, B90_ROOT.username, B90_ROOT.password);

// Nothing is loaded while the field is empty — no tracking by default.
// Start from "not configured" explicitly rather than assuming it: this suite
// shares one database, and that state is what the assertion is about.
expect(
(await page.request.put(endpoint, { data: { url: "" } })).status(),
).toBe(200);
await page.goto("/fr/tools");
await expect(page.locator(`script[src="${trackingUrl}"]`)).toHaveCount(0);

expect(
(
await page.request.put(endpoint, { data: { url: "pas-une-url" } })
).status(),
).toBe(400);
expect(
(
await page.request.put(endpoint, {
data: { url: trackingUrl, websiteId },
})
).status(),
).toBe(200);

// A public page and an admin page: the root layout covers both.
for (const path of ["/fr/tools", "/admin/config"]) {
const response = await page.goto(path);
const csp = response?.headers()["content-security-policy"];
const script = page.locator(`script[src="${trackingUrl}"]`);
await expect(script, `${path}: tracking script missing`).toHaveCount(1);

// Browsers hide the nonce content attribute; the IDL property keeps it.
const nonce = await script.evaluate(
(element) => (element as HTMLScriptElement).nonce,
);
expect(nonce, `${path}: no nonce on the tracking script`).toBeTruthy();

// Bloc 101: the identifier the tracker expects next to its src. Without
// it, Umami loads and measures nothing.
await expect(script, `${path}: data-website-id missing`).toHaveAttribute(
"data-website-id",
websiteId,
);
expect(csp, `${path}: the CSP does not authorise that nonce`).toContain(
`'nonce-${nonce}'`,
);

// And the browser agrees: it reported no policy violation. The host is
// unreachable on purpose, so the fetch fails at the network — a refusal
// by the CSP would have shown up here instead.
expect(
await page.evaluate(
() => (window as unknown as { cspViolations: string[] }).cspViolations,
),
`${path}: CSP violation with the tracking script in place`,
).toEqual([]);
}

// Bloc 101: the identifier is optional — clearing it alone leaves the script
// loading, with `src` and nothing else.
expect(
(
await page.request.put(endpoint, {
data: { url: trackingUrl, websiteId: "" },
})
).status(),
).toBe(200);
await page.goto("/fr/tools");
const bare = page.locator(`script[src="${trackingUrl}"]`);
await expect(bare).toHaveCount(1);
await expect(bare).not.toHaveAttribute("data-website-id", /.*/);
// And a value that is not an identifier is refused rather than stored.
expect(
(
await page.request.put(endpoint, {
data: { url: trackingUrl, websiteId: '"><script>' },
})
).status(),
).toBe(400);

// The admin fields show what was stored, and clearing the URL stops the
// loading.
await page.goto("/admin/config");
await expect(page.getByLabel("URL du script de suivi")).toHaveValue(
trackingUrl,
);

// Revue Codex (PR #127): an `admin` has configuration.write, but setting an
// executable script URL is super_admin only — otherwise that admin runs code
// of their choosing on the next page a Super Admin loads, with their
// session, which is the users.manage the role matrix denies them.
const created = await page.request.post("/api/admin/users", {
data: {
username: "b100-admin",
role: "admin",
password: "role-test-password",
},
});
// 201 the first time; this API answers 400 for an existing username, which
// is what a re-run against the same database gets. Either way the account
// exists with that password, which is all the check below needs.
expect([201, 400]).toContain(created.status());
const adminContext = await page.context().browser()!.newContext();
const adminPage = await adminContext.newPage();
await b90Login(adminPage, "b100-admin", "role-test-password");
expect(
(
await adminPage.request.put(endpoint, {
data: { url: "https://evil.example.test/x.js" },
})
).status(),
"an admin must not be able to set the tracking script",
).toBe(403);
// And the field is not even shown to them on the Configuration tab.
await adminPage.goto("/admin/config");
await expect(adminPage.getByLabel("URL du script de suivi")).toHaveCount(0);
await expect(adminPage.getByRole("cell", { name: "Deutsch" })).toBeVisible();
await adminContext.close();

expect(
(await page.request.put(endpoint, { data: { url: "" } })).status(),
).toBe(200);
await page.goto("/fr/tools");
await expect(page.locator(`script[src="${trackingUrl}"]`)).toHaveCount(0);
});

// Bloc 90/D: EN and FR can never be deactivated — their toggles are locked in
// the UI, and a forged API request to disable them is rejected.
test("Bloc 90/D: English and French cannot be deactivated", async ({
Expand Down
Loading
Loading