diff --git a/TODO.md b/TODO.md index d6e56a8..cafc08d 100644 --- a/TODO.md +++ b/TODO.md @@ -63,11 +63,11 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Playwright baseline with web/admin projects - [x] [P1] CI workflow for lint/typecheck/unit/e2e gates - [x] [P1] Test data strategy (seed fixtures + isolated e2e data) -- [~] [P1] RBAC policy unit tests and permission regression suite -- [ ] [P1] i18n unit tests (locale resolution, fallback, message key loading) +- [x] [P1] RBAC policy unit tests and permission regression suite +- [x] [P1] i18n unit tests (locale resolution, fallback, message key loading) - [x] [P1] i18n integration tests (admin/public locale switch and persistence) -- [ ] [P1] i18n e2e smoke tests (localized headings/content per route) -- [ ] [P1] CRUD contract tests for shared service patterns +- [x] [P1] i18n e2e smoke tests (localized headings/content per route) +- [x] [P1] CRUD contract tests for shared service patterns ### Documentation @@ -205,6 +205,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service. - [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`). - [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`). +- [2026-02-10] Testing baseline now includes explicit RBAC regression checks, locale-resolution unit tests (admin/web), CRUD service contract tests, and i18n smoke e2e routes. ## How We Use This File diff --git a/apps/admin/src/i18n/server.test.ts b/apps/admin/src/i18n/server.test.ts new file mode 100644 index 0000000..5aa8123 --- /dev/null +++ b/apps/admin/src/i18n/server.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest" + +import { resolveAdminLocaleFromCookieValue } from "./server" + +describe("resolveAdminLocaleFromCookieValue", () => { + it("accepts supported locales", () => { + expect(resolveAdminLocaleFromCookieValue("de")).toBe("de") + expect(resolveAdminLocaleFromCookieValue("en")).toBe("en") + expect(resolveAdminLocaleFromCookieValue("es")).toBe("es") + expect(resolveAdminLocaleFromCookieValue("fr")).toBe("fr") + }) + + it("falls back to default locale for unknown values", () => { + expect(resolveAdminLocaleFromCookieValue("it")).toBe("en") + expect(resolveAdminLocaleFromCookieValue(undefined)).toBe("en") + }) +}) diff --git a/apps/admin/src/i18n/server.ts b/apps/admin/src/i18n/server.ts index 25aa29e..f6a6fce 100644 --- a/apps/admin/src/i18n/server.ts +++ b/apps/admin/src/i18n/server.ts @@ -4,10 +4,7 @@ import { cookies } from "next/headers" import type { AdminMessages } from "./messages" import { ADMIN_LOCALE_COOKIE } from "./shared" -export async function resolveAdminLocale(): Promise { - const cookieStore = await cookies() - const value = cookieStore.get(ADMIN_LOCALE_COOKIE)?.value - +export function resolveAdminLocaleFromCookieValue(value: string | undefined): AppLocale { if (value && isAppLocale(value)) { return value } @@ -15,6 +12,12 @@ export async function resolveAdminLocale(): Promise { return defaultLocale } +export async function resolveAdminLocale(): Promise { + const cookieStore = await cookies() + const value = cookieStore.get(ADMIN_LOCALE_COOKIE)?.value + return resolveAdminLocaleFromCookieValue(value) +} + export async function getAdminMessages(locale: AppLocale): Promise { return (await import(`../messages/${locale}.json`)).default as AdminMessages } diff --git a/apps/web/src/i18n/request.test.ts b/apps/web/src/i18n/request.test.ts new file mode 100644 index 0000000..936cfea --- /dev/null +++ b/apps/web/src/i18n/request.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest" + +import { resolveRequestLocale } from "./request" + +describe("resolveRequestLocale", () => { + it("accepts supported locales", () => { + expect(resolveRequestLocale("de")).toBe("de") + expect(resolveRequestLocale("en")).toBe("en") + expect(resolveRequestLocale("es")).toBe("es") + expect(resolveRequestLocale("fr")).toBe("fr") + }) + + it("falls back to default locale for unsupported values", () => { + expect(resolveRequestLocale("it")).toBe("en") + expect(resolveRequestLocale(undefined)).toBe("en") + }) +}) diff --git a/apps/web/src/i18n/request.ts b/apps/web/src/i18n/request.ts index ff37c18..57bd38e 100644 --- a/apps/web/src/i18n/request.ts +++ b/apps/web/src/i18n/request.ts @@ -1,11 +1,16 @@ +import type { AppLocale } from "@cms/i18n" import { hasLocale } from "next-intl" import { getRequestConfig } from "next-intl/server" import { routing } from "./routing" +export function resolveRequestLocale(requested: string | undefined): AppLocale { + return hasLocale(routing.locales, requested) ? requested : routing.defaultLocale +} + export default getRequestConfig(async ({ requestLocale }) => { const requested = await requestLocale - const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale + const locale = resolveRequestLocale(requested) return { locale, diff --git a/e2e/i18n-smoke.pw.ts b/e2e/i18n-smoke.pw.ts new file mode 100644 index 0000000..f74f43b --- /dev/null +++ b/e2e/i18n-smoke.pw.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test" + +test.describe("i18n smoke", () => { + test("web renders localized page headings on key routes", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "web-chromium") + + await page.goto("/") + await page.locator("select").first().selectOption("de") + await expect(page.getByRole("heading", { name: /dein next\.js cms frontend/i })).toBeVisible() + + await page.getByRole("link", { name: /über uns/i }).click() + await expect(page.getByRole("heading", { name: /über dieses projekt/i })).toBeVisible() + + await page.locator("select").first().selectOption("es") + await expect(page.getByRole("heading", { name: /sobre este proyecto/i })).toBeVisible() + + await page.getByRole("link", { name: /contacto/i }).click() + await expect(page.getByRole("heading", { name: /^contacto$/i })).toBeVisible() + }) + + test("admin login renders localized heading and labels", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "admin-chromium") + + await page.goto("/login") + await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible() + + await page.locator("select").first().selectOption("fr") + await expect(page.getByRole("heading", { name: /se connecter à cms admin/i })).toBeVisible() + await expect(page.getByLabel(/e-mail ou nom d’utilisateur/i)).toBeVisible() + + await page.locator("select").first().selectOption("es") + await expect(page.getByRole("heading", { name: /iniciar sesión en cms admin/i })).toBeVisible() + await expect(page.getByLabel(/correo o nombre de usuario/i)).toBeVisible() + }) +}) diff --git a/packages/content/src/rbac.test.ts b/packages/content/src/rbac.test.ts index e534141..9c38019 100644 --- a/packages/content/src/rbac.test.ts +++ b/packages/content/src/rbac.test.ts @@ -28,4 +28,31 @@ describe("rbac model", () => { expect(permissionMatrix.editor.length).toBeGreaterThan(0) expect(permissionMatrix.manager.length).toBeGreaterThan(0) }) + + it("prevents privilege escalation for non-admin roles", () => { + expect(hasPermission("editor", "users:manage_roles", "global")).toBe(false) + expect(hasPermission("manager", "users:manage_roles", "global")).toBe(false) + expect(hasPermission("editor", "dashboard:read", "global")).toBe(true) + }) + + it("keeps role policy regressions visible for critical permissions", () => { + const criticalChecks: Array<{ + role: "owner" | "support" | "admin" | "manager" | "editor" + permission: Parameters[1] + scope: Parameters[2] + allowed: boolean + }> = [ + { role: "owner", permission: "users:manage_roles", scope: "global", allowed: true }, + { role: "support", permission: "users:manage_roles", scope: "global", allowed: true }, + { role: "admin", permission: "banner:write", scope: "global", allowed: true }, + { role: "manager", permission: "users:write", scope: "global", allowed: false }, + { role: "manager", permission: "users:write", scope: "team", allowed: true }, + { role: "editor", permission: "news:publish", scope: "team", allowed: false }, + { role: "editor", permission: "news:publish", scope: "own", allowed: true }, + ] + + for (const check of criticalChecks) { + expect(hasPermission(check.role, check.permission, check.scope)).toBe(check.allowed) + } + }) }) diff --git a/packages/crud/src/contract.test.ts b/packages/crud/src/contract.test.ts new file mode 100644 index 0000000..1526eaf --- /dev/null +++ b/packages/crud/src/contract.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest" +import { z } from "zod" + +import { createCrudService } from "./service" + +type RecordItem = { + id: string + title: string +} + +describe("crud service contract", () => { + it("calls repository in expected order for update and delete", async () => { + const calls: string[] = [] + const state = new Map([["1", { id: "1", title: "Initial" }]]) + + const service = createCrudService({ + resource: "item", + repository: { + list: async () => { + calls.push("list") + return Array.from(state.values()) + }, + findById: async (id) => { + calls.push(`findById:${id}`) + return state.get(id) ?? null + }, + create: async (input: { title: string }) => { + calls.push("create") + return { + id: "2", + title: input.title, + } + }, + update: async (id, input: { title?: string }) => { + calls.push(`update:${id}`) + const current = state.get(id) + if (!current) { + throw new Error("missing") + } + const updated = { + ...current, + ...input, + } + state.set(id, updated) + return updated + }, + delete: async (id) => { + calls.push(`delete:${id}`) + const current = state.get(id) + if (!current) { + throw new Error("missing") + } + state.delete(id) + return current + }, + }, + schemas: { + create: z.object({ + title: z.string().min(3), + }), + update: z.object({ + title: z.string().min(3).optional(), + }), + }, + }) + + await service.update("1", { title: "Updated" }) + await service.delete("1") + + expect(calls).toEqual(["findById:1", "update:1", "findById:1", "delete:1"]) + }) + + it("passes parsed payload to repository create/update contracts", async () => { + let createPayload: unknown = null + let updatePayload: unknown = null + + const service = createCrudService({ + resource: "item", + repository: { + list: async () => [], + findById: async () => ({ + id: "1", + title: "Existing", + }), + create: async (input: { title: string }) => { + createPayload = input + return { + id: "2", + title: input.title, + } + }, + update: async (_id, input: { title?: string }) => { + updatePayload = input + return { + id: "1", + title: input.title ?? "Existing", + } + }, + delete: async () => ({ + id: "1", + title: "Existing", + }), + }, + schemas: { + create: z.object({ + title: z.string().trim().min(3), + }), + update: z.object({ + title: z.string().trim().min(3).optional(), + }), + }, + }) + + await service.create({ + title: " Created ", + }) + await service.update("1", { + title: " Updated ", + }) + + expect(createPayload).toEqual({ title: "Created" }) + expect(updatePayload).toEqual({ title: "Updated" }) + }) +})