diff --git a/.env.example b/.env.example index 9259888..b25863b 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,33 @@ # Skilluv Admin — environment variables -# Copy to `.env` and adjust for local dev. Do NOT commit `.env`. +# Copy to `.env` (gitignored) and adjust for your setup. -# Internal URL of the Skilluv backend (server-side only, used by -# hooks.server.ts for SSR calls). The browser side goes through the -# `/api` proxy declared in vite.config.ts, so this value only matters -# when running under `node build` (production adapter-node output). -API_URL=http://localhost:3001/api +# ─── SSR + dev proxy ───────────────────────────────────────────────── +# Internal URL of the Skilluv backend used server-side by hooks.server.ts +# for the /auth/me call. Always ends in /api. +# Deployed backend (default recommendation): +API_URL=https://api.skill-uv.com/api +# Local Rust backend (docker stack + cargo run): +# API_URL=http://localhost:3001/api -# --- Playwright e2e (optional) --- -# Postgres URL used by `e2e/admin-back-e2e.spec.ts` to seed a test -# admin directly in DB. Leave empty to skip the e2e suite. -# SKILLUV_PG_URL=postgres://skilluv:skilluv_secret@localhost:5433/skilluv +# Where the vite dev server proxies /api/*. Must be the same host as +# API_URL minus the /api suffix. +VITE_API_PROXY_TARGET=https://api.skill-uv.com +# VITE_API_PROXY_TARGET=http://localhost:3001 -# Backend URL used by the same e2e suite for the pre-flight health -# check. Skipped if unreachable. -# SKILLUV_BACKEND=http://localhost:3001 +# ─── Analytics validation (P26 v2, SKI-100 section 5) ──────────────── +# Dashboard Grafana qui trace les compteurs Prometheus du workflow +# challenge (ingestion, refresh externe, webhook CI, bonus merge). La page +# /validation-analytics affiche un lien vers cette URL quand elle est +# renseignée, et un rappel de configuration sinon. +# PUBLIC_GRAFANA_URL=https://grafana.skill-uv.com/d/skilluv-challenges + +# ─── Playwright E2E ────────────────────────────────────────────────── +# When set, e2e/setup/bootstrap-admin.mjs and the admin-project specs +# talk to this backend + Postgres directly. Leave empty to run only the +# `public` Playwright project (no backend needed). +BACKEND_URL=https://api.skill-uv.com +# DATABASE_URL is required by the `admin` project: bootstrap-admin.mjs +# grants the admin role in SQL, and 14 of 17 admin specs seed fixtures via +# e2e/setup/db.ts. Point it at the Postgres backing whichever BACKEND_URL +# you chose — the local docker stack exposes it on 5433. +# DATABASE_URL=postgres://skilluv:CHANGE_ME@localhost:5433/skilluv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3373711..ef2926a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: npm run build e2e: - name: Playwright smoke tests + name: Playwright public smoke runs-on: ubuntu-latest needs: check timeout-minutes: 15 @@ -54,13 +54,164 @@ jobs: - name: Build run: npm run build - - name: Run smoke tests - run: npm run test:e2e + - name: Run public smoke tests + run: npx playwright test --project=public - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-report + name: playwright-public-report + path: playwright-report/ + retention-days: 7 + + e2e-admin: + # Runs the authenticated `admin` Playwright project against a real backend + # pulled from GHCR. Requires the backend team's publish workflow to be + # merged first (see skilluv-backend PR #33). This job will stay red on + # PRs until that image is published — that's intentional; we don't skip + # tests when a dependency isn't ready, we surface the gap. + name: Playwright admin flows (needs backend image) + runs-on: ubuntu-latest + needs: check + timeout-minutes: 20 + + services: + postgres: + image: postgres:18.4-alpine + env: + POSTGRES_USER: skilluv + POSTGRES_PASSWORD: skilluv_secret + POSTGRES_DB: skilluv + # postgres 18+ warns when data lives at /var/lib/postgresql/data + # (the legacy mount path). Force a subdir to silence the check. + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U skilluv" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + redis: + image: redis:8.8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + mailpit: + image: axllent/mailpit:latest + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd "wget -q --spider http://localhost:8025 || exit 1" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v7 + + # MinIO can't be a GHA service (the image needs a `server /data` arg; + # services don't support commands). Docker-run it on host network so + # the backend (also host-net) reaches it via localhost:9000. + - name: Start MinIO + run: | + docker run -d --name minio --network host \ + -e MINIO_ROOT_USER=skilluv \ + -e MINIO_ROOT_PASSWORD=skilluv_secret \ + minio/minio:RELEASE.2025-09-07T16-13-09Z \ + server /data + for i in $(seq 1 30); do + curl -fsS http://localhost:9000/minio/health/live > /dev/null 2>&1 && \ + echo "minio ready after ${i}s" && exit 0 + sleep 1 + done + docker logs minio + exit 1 + + - name: Start backend from GHCR image + env: + # `:master` is republished on every green master merge — see + # skilluv-backend/.github/workflows/ci.yml `publish` job. + BACKEND_IMAGE: ghcr.io/skilluv/skilluv-backend:master + run: | + docker pull "$BACKEND_IMAGE" + # Host network — backend reaches postgres/redis/mailpit/minio via + # the ports GHA services (and MinIO above) already bound to the + # runner. Admin origin allowlist added so the API accepts + # requests from the test's Origin: http://localhost:5174. + docker run -d --name backend --network host \ + -e HOST=0.0.0.0 \ + -e PORT=3001 \ + -e ENVIRONMENT=dev \ + -e DATABASE_URL=postgres://skilluv:skilluv_secret@localhost:5433/skilluv \ + -e REDIS_URL=redis://localhost:6379 \ + -e JWT_SECRET=ci-test-secret-please-rotate \ + -e BASE_URL=http://localhost:3001 \ + -e MINIO_ENDPOINT=http://localhost:9000 \ + -e MINIO_ACCESS_KEY=skilluv \ + -e MINIO_SECRET_KEY=skilluv_secret \ + -e MINIO_BUCKET=avatars \ + -e SMTP_HOST=localhost \ + -e SMTP_PORT=1025 \ + -e SMTP_TLS=none \ + -e EMAIL_FROM=noreply@skilluv.test \ + -e ADMIN_ORIGINS=http://localhost:5174 \ + -e RUST_LOG=skilluv_backend=info,tower_http=info \ + "$BACKEND_IMAGE" + + - name: Wait for backend to be healthy + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:3001/api/health > /dev/null 2>&1; then + echo "backend ready after ${i}s" + exit 0 + fi + sleep 2 + done + echo "backend never became healthy — dumping logs:" + docker logs backend + exit 1 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Bootstrap admin user (register + elevate + enable 2FA) + env: + BACKEND_URL: http://localhost:3001 + DATABASE_URL: postgres://skilluv:skilluv_secret@localhost:5433/skilluv + run: node e2e/setup/bootstrap-admin.mjs + + - name: Run admin Playwright project + env: + BACKEND_URL: http://localhost:3001 + DATABASE_URL: postgres://skilluv:skilluv_secret@localhost:5433/skilluv + run: npx playwright test --project=admin + + - name: Dump backend logs on failure + if: failure() + run: docker logs backend + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-admin-report path: playwright-report/ retention-days: 7 diff --git a/.gitignore b/.gitignore index 1215999..e32a20e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,14 @@ playwright-report test-results .playwright +# E2E admin bootstrap artifacts (contain test creds + session state) +e2e/setup/admin-credentials.json +e2e/setup/admin-storage-state.json +e2e/setup/*.png + +# Trello sync creds (see qa/.trello.env.example) +qa/.trello.env + # AI coding assistant caches .claude/ .claude.* diff --git a/README.md b/README.md index 85574b3..0cf06eb 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,16 @@ ## What is Skilluv? -Skilluv is a community platform training the African OSS generation through real contributions to real open source projects. Every completed challenge produces a verifiable artifact — a merged pull request, a delivered Figma component, a submitted CVE report, a playable game build — exportable to recruiters. +Skilluv is a platform where talents in code, design, security, game +development, product, and other tech crafts grow their skills by +contributing to **real open source projects**. Every completed challenge +produces a verifiable artifact — a merged pull request, a delivered +Figma component, a submitted CVE report, a playable game build — +exportable to recruiters. -Full product vision in the [backend repository](https://github.com/skilluv/skilluv-backend). +**African-born, globally open.** Contributors and hiring companies come +from anywhere. Full product vision in the +[org profile](https://github.com/skilluv). ## What this repo contains @@ -133,4 +140,4 @@ Distributed under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3. ## Origin -Skilluv is built solo by [Jeremie Zitti](https://github.com/skilluv), a Beninese engineer. Public launch: **January 2027**. +Skilluv is built solo by [Jeremie Zitti](https://github.com/skilluv), an engineer based in Benin. Public launch: **January 2027**. diff --git a/e2e/admin/catalog-crud.spec.ts b/e2e/admin/catalog-crud.spec.ts new file mode 100644 index 0000000..fd6816a --- /dev/null +++ b/e2e/admin/catalog-crud.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq } from '../setup/db'; + +// Phase 3 — catalog admin CRUD: orientations + badge rules + tenants. +// Grouped here because each individually is short but shares the /catalog +// tab surface + similar seed patterns. + +// ─── Orientations ──────────────────────────────────────────────────────── + +async function readOrientation(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + // La colonne s'appelle `name` : `display_name` n'existe pas sur + // `orientations` (elle existe sur `badge_rules`, d'où la confusion). + 'SELECT id, name, description FROM orientations WHERE slug = $1', + [slug] + ); + return rows[0] as { id: string; name: string; description: string | null } | undefined; + }); +} + +async function cleanupOrientation(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM orientations WHERE slug = $1', [slug]); + }); +} + +test('admin creates an orientation from /catalog', async ({ page }) => { + const id = uniq(); + const slug = `e2e-orient-${id}`; + const displayName = `E2E Orientation ${id}`; + + // Attendre le chargement de l'onglet : cliquer avant l'hydratation ne + // déclenche rien et la modale ne s'ouvre jamais. + // L'onglet lit le catalogue public `/orientations` ; seules les mutations + // passent par `/admin/orientations`. + const listed = page.waitForResponse( + (r) => r.url().includes('/orientations') && r.request().method() === 'GET' + ); + await page.goto('/catalog'); + await listed; + + // Libellé réel : « Créer une orientation » (i18n `orientations.createBtn`). + await page.getByRole('button', { name: /créer une orientation/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Les champs sont des , donc adressables par leur libellé + // plutôt que par un id ou un placeholder qui n'existent pas. + await dialog.getByLabel(/slug/i).first().fill(slug); + await dialog.getByLabel(/nom affiché/i).first().fill(displayName); + + // La modale n'est pas un
: `requestSubmit()` n'avait rien à appeler. + // Le bouton primaire des actions est le point de soumission. + const req = page.waitForResponse( + (r) => r.url().includes('/admin/orientations') && r.request().method() === 'POST' + ); + await dialog.getByRole('button', { name: /^créer$/i }).click(); + expect((await req).status(), 'orientation POST').toBeLessThan(300); + + const created = await readOrientation(slug); + expect(created?.name).toBe(displayName); + + await cleanupOrientation(slug); +}); + +// ─── Badge rules ──────────────────────────────────────────────────────── + +async function readBadgeRule(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT id, display_name, deprecated_at FROM badge_rules WHERE slug = $1', + [slug] + ); + return rows[0] as + | { id: string; display_name: string; deprecated_at: Date | null } + | undefined; + }); +} + +async function seedBadgeRule() { + const id = uniq(); + const slug = `e2e-badge-${id}`; + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO badge_rules (slug, output_type, display_name, description, conditions) + VALUES ($1, 'medal', $2, 'E2E test rule', '{}'::jsonb) + RETURNING id`, + [slug, `E2E Badge ${id}`] + ); + return { id: rows[0].id as string, slug }; + }); +} + +async function cleanupBadgeRule(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM badge_rules WHERE slug = $1', [slug]); + }); +} + +test('admin deprecates a badge rule from /catalog', async ({ page }) => { + const rule = await seedBadgeRule(); + + // Attendre le premier chargement de l'onglet par défaut avant de basculer : + // un clic avant hydratation ne change pas d'onglet. + const orientationsLoaded = page.waitForResponse( + (r) => r.url().includes('/orientations') && r.request().method() === 'GET' + ); + await page.goto('/catalog'); + await orientationsLoaded; + + const rulesLoaded = page.waitForResponse((r) => r.url().includes('/badge-rules')); + await page.getByRole('button', { name: 'Badge rules' }).click(); + await rulesLoaded; + + // Cibler la ligne de la règle seedée : `.first()` cliquait la première + // règle de la liste, donc la requête attendue ne partait jamais. + const row = page.locator('tbody tr', { hasText: rule.slug }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await row.getByRole('button', { name: /déprécier|deprecate/i }).click(); + + // Deprecate is destructive → reason required. + await page.getByTestId('confirm-dangerous-reason').fill('E2E — rule superseded by newer criteria'); + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/badge-rules/${rule.slug}/deprecate`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await req).status(), 'deprecate POST').toBeLessThan(300); + + const state = await readBadgeRule(rule.slug); + expect(state?.deprecated_at, 'deprecated_at set').not.toBeNull(); + + await cleanupBadgeRule(rule.slug); +}); + +// ─── Tenants ──────────────────────────────────────────────────────────── + +async function readTenant(slug: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT id, name, plan FROM tenants WHERE slug = $1', + [slug] + ); + return rows[0] as { id: string; name: string; plan: string } | undefined; + }); +} + +async function cleanupTenant(slug: string) { + await withDb(async (client) => { + await client.query('DELETE FROM tenants WHERE slug = $1', [slug]); + }); +} + +test('admin creates a tenant from /tenants', async ({ page }) => { + const id = uniq(); + const slug = `e2e-tenant-${id}`.slice(0, 40); + const name = `E2E Tenant ${id}`; + + await page.goto('/tenants'); + await page.waitForResponse((r) => r.url().includes('/api/admin/tenants') && r.request().method() === 'GET'); + + await page.getByRole('button', { name: /nouveau tenant|new tenant|créer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Les ids de la modale tenant sont préfixés `t-` : `#slug` ne matchait rien. + await dialog.locator('#t-slug').fill(slug); + await dialog.locator('#t-name').fill(name); + // Contact email is required by the create endpoint. + await dialog.locator('#t-email').fill(`${slug}@e2e.test`); + + const req = page.waitForResponse( + (r) => r.url().includes('/admin/tenants') && r.request().method() === 'POST' + ); + await dialog.locator('form').evaluate((f: HTMLFormElement) => f.requestSubmit()); + expect((await req).status(), 'tenant POST').toBeLessThan(300); + + const created = await readTenant(slug); + expect(created?.name).toBe(name); + + await cleanupTenant(slug); +}); diff --git a/e2e/admin/challenge-lifecycle.spec.ts b/e2e/admin/challenge-lifecycle.spec.ts new file mode 100644 index 0000000..c911199 --- /dev/null +++ b/e2e/admin/challenge-lifecycle.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq } from '../setup/db'; + +// Phase 2 — challenge admin lifecycle: seeded challenge → publish via UI → +// archive via UI. Backend enforces "hard rule #1" (challenges published must be +// is_training=TRUE or have project_id); we set is_training when seeding so the +// publish button doesn't 400. + +async function seedDraftChallenge(page: import('@playwright/test').Page) { + const title = `E2E Challenge ${uniq()}`; + return await page.evaluate(async ({ title }) => { + const r = await fetch('/api/admin/challenges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description: 'Seeded by e2e/admin/challenge-lifecycle.spec.ts', + instructions: 'Complete the E2E lifecycle test.', + skill_domain: 'code', + difficulty: 3, + is_training: true + }) + }); + if (!r.ok) throw new Error(`create failed: ${r.status} ${await r.text()}`); + return (await r.json()).data.challenge as { id: string; title: string }; + }, { title }); +} + +async function readStatus(challengeId: string) { + return withDb(async (client) => { + const { rows } = await client.query('SELECT status FROM challenge_templates WHERE id = $1', [challengeId]); + return rows[0]?.status as string | undefined; + }); +} + +test('admin can publish then archive a draft challenge via the UI', async ({ page }) => { + // Land on /challenges first so we're in the admin session context for the fetch. + await page.goto('/challenges'); + await page.waitForResponse( + (r) => r.url().includes('/api/admin/challenges') && r.request().method() === 'GET' + ); + + const challenge = await seedDraftChallenge(page); + expect(await readStatus(challenge.id), 'seeded challenge starts as draft').toBe('draft'); + + // Reload so the freshly-seeded challenge shows up in the list. + const listAfterSeed = page.waitForResponse( + (r) => r.url().includes('/api/admin/challenges') && r.request().method() === 'GET' + ); + await page.reload(); + await listAfterSeed; + + // Anchor the row by the challenge title span, then walk up to the outer card. + const titleSpan = page.locator('span').filter({ hasText: challenge.title }).first(); + await expect(titleSpan).toBeVisible({ timeout: 10_000 }); + const row = titleSpan.locator('xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]'); + + // ─── Publish ───────────────────────────────────────────────────── + const publishReq = page.waitForResponse( + (r) => r.url().includes(`/admin/challenges/${challenge.id}/publish`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /publier|publish/i }).click(); + expect((await publishReq).status(), 'publish POST').toBeLessThan(300); + expect(await readStatus(challenge.id), 'DB status after publish').toBe('published'); + + // ─── Archive ───────────────────────────────────────────────────── + const archiveReq = page.waitForResponse( + (r) => r.url().includes(`/admin/challenges/${challenge.id}/archive`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /archiver|archive/i }).click(); + expect((await archiveReq).status(), 'archive POST').toBeLessThan(300); + expect(await readStatus(challenge.id), 'DB status after archive').toBe('archived'); +}); diff --git a/e2e/admin/community-review.spec.ts b/e2e/admin/community-review.spec.ts new file mode 100644 index 0000000..bc2738a --- /dev/null +++ b/e2e/admin/community-review.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 2 — community-submitted challenges: approve + reject via the UI, DB confirms. + +async function seedCommunityChallenge() { + const id = uniq(); + const title = `E2E Community Challenge ${id}`; + const creator = await seedUser({ prefix: 'creator' }); + return withDb(async (client) => { + // `is_training=TRUE` — required so the approve handler's implicit + // `status='published'` UPDATE doesn't violate the DB check constraint + // `challenge_templates_project_or_training` (see BUGS_BACK). + const { rows } = await client.query( + `INSERT INTO challenge_templates + (title, description, instructions, skill_domain, difficulty, created_by, + is_community, community_status, is_training, title_i18n) + VALUES ($1, 'E2E description', 'E2E instructions', 'code', 3, $2, + TRUE, 'review', TRUE, $3::jsonb) + RETURNING id`, + [title, creator.id, JSON.stringify({ fr: title })] + ); + return { challengeId: rows[0].id as string, title }; + }); +} + +async function readChallenge(challengeId: string) { + return withDb(async (client) => { + const { rows } = await client.query( + 'SELECT status, community_status FROM challenge_templates WHERE id = $1', + [challengeId] + ); + return rows[0] as { status: string; community_status: string | null } | undefined; + }); +} + +async function landOnReviewPage(page: import('@playwright/test').Page, challengeTitle: string) { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/community/review') && r.request().method() === 'GET' + ); + await page.goto('/community'); + await initialLoad; + const titleH3 = page.getByRole('heading', { name: challengeTitle }); + await expect(titleH3).toBeVisible({ timeout: 10_000 }); + return titleH3.locator( + 'xpath=ancestor::div[contains(@class,"rounded-2xl") and contains(@class,"border-border")][1]' + ); +} + +test('admin can approve a community challenge under review', async ({ page }) => { + const { challengeId, title } = await seedCommunityChallenge(); + const card = await landOnReviewPage(page, title); + + const approveReq = page.waitForResponse( + (r) => r.url().includes(`/admin/community/${challengeId}/approve`) && r.request().method() === 'POST' + ); + await card.getByRole('button', { name: /^approuver$|^approve$/i }).click(); + expect((await approveReq).status(), 'approve POST').toBeLessThan(300); + const state = await readChallenge(challengeId); + expect(state?.community_status, 'community_status after approve').toBe('approved'); +}); + +test('approving a community challenge without is_training/project returns 400 with actionable message', async ({ page }) => { + // Regression guard for Trello hVImXbUS — backend used to bubble a + // generic 500 when the DB trigger for hard rule P3 (published requires + // is_training or project_id) fired. Now it pre-checks and returns 400 + // with a message explaining what's missing. + const id = uniq(); + const title = `E2E Bad Approve ${id}`; + const creator = await seedUser({ prefix: 'creator-bad' }); + const { challengeId } = await withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO challenge_templates + (title, description, instructions, skill_domain, difficulty, created_by, + is_community, community_status, is_training, project_id, title_i18n) + VALUES ($1, 'no-training no-project', 'x', 'code', 3, $2, + TRUE, 'review', FALSE, NULL, $3::jsonb) + RETURNING id`, + [title, creator.id, JSON.stringify({ fr: title })] + ); + return { challengeId: rows[0].id as string }; + }); + + await page.goto('/'); + const status = await page.evaluate(async ({ id }) => { + const r = await fetch(`/api/admin/community/${id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + return { status: r.status, body: await r.text() }; + }, { id: challengeId }); + + expect(status.status, 'expected 400, not 500').toBe(400); + expect(status.body.toLowerCase()).toMatch(/is_training|project/); + + // Verify the DB was NOT mutated (approve was properly refused). + const state = await readChallenge(challengeId); + expect(state?.community_status, 'community_status untouched').toBe('review'); + expect(state?.status, 'status untouched').not.toBe('published'); +}); + +test('admin can reject a community challenge with feedback', async ({ page }) => { + const { challengeId, title } = await seedCommunityChallenge(); + const card = await landOnReviewPage(page, title); + + await card.getByRole('button', { name: /rejeter|reject/i }).click(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E — challenge non aligné avec les guidelines'); + + const rejectReq = page.waitForResponse( + (r) => r.url().includes(`/admin/community/${challengeId}/reject`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await rejectReq).status(), 'reject POST').toBeLessThan(300); + const state = await readChallenge(challengeId); + expect(state?.community_status, 'community_status after reject').toBe('rejected'); +}); diff --git a/e2e/admin/fraud-actions.spec.ts b/e2e/admin/fraud-actions.spec.ts new file mode 100644 index 0000000..ef3602a --- /dev/null +++ b/e2e/admin/fraud-actions.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { withDb, seedUser, seedProject, seedSlice } from '../setup/db'; + +// Phase 2 — fraud queue: mark-valid + revoke a flagged deliverable via the UI. +// Backend `list_flagged` returns deliverables with plagiarism_score >= 0.9. + +async function seedFlaggedDeliverable() { + const user = await seedUser({ prefix: 'fraud' }); + // La contrainte `deliverables_at_least_one_parent` impose un rattachement à + // une slice ou à un challenge : un livrable orphelin n'existe pas en base. + // On ancre donc sur un projet + une slice jetables, nettoyés ensuite. + const project = await seedProject({ ownerId: user.id, slugPrefix: 'e2e-fraud' }); + const slice = await seedSlice({ projectId: project.id, status: 'validated' }); + return withDb(async (client) => { + const { rows } = await client.query( + `INSERT INTO deliverables + (user_id, slice_id, artifact_type, artifact_url, verifiable_by, plagiarism_score) + -- verifiable_by n'accepte plus 'ai' : les valeurs sont + -- github_webhook / human_review / automated_diff / third_party_api / ci_status. + VALUES ($1, $2, 'pr_merged', 'https://e2e.test/artifact', 'human_review', 0.95) + RETURNING id`, + [user.id, slice.id] + ); + return { deliverableId: rows[0].id as string, projectId: project.id, userId: user.id }; + }); +} + +async function readDeliverable(id: string) { + return withDb(async (client) => { + const { rows } = await client.query( + // Le backend pose `revoked_at` + `revocation_reason` ; il ne touche pas + // `verification_status`. Le spec assérait donc un effet inexistant. + 'SELECT plagiarism_score, verification_status, revoked_at FROM deliverables WHERE id = $1', + [id] + ); + return rows[0] as + | { plagiarism_score: string | null; verification_status: string; revoked_at: Date | null } + | undefined; + }); +} + +async function landOnFraudTab(page: import('@playwright/test').Page, deliverableId: string) { + const initialLoad = page.waitForResponse( + (r) => r.url().includes('/api/admin/fraud/queue') && r.request().method() === 'GET' + ); + await page.goto('/fraud'); + await initialLoad; + // Deliverable id shows in the plagiarism table's first column. + const cell = page.getByText(deliverableId, { exact: false }); + await expect(cell).toBeVisible({ timeout: 10_000 }); + return cell.locator('xpath=ancestor::tr[1]'); +} + +test('admin can mark a flagged deliverable as valid', async ({ page }) => { + const { deliverableId } = await seedFlaggedDeliverable(); + const row = await landOnFraudTab(page, deliverableId); + + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/fraud/deliverables/${deliverableId}/mark-valid`) && r.request().method() === 'POST' + ); + await row.getByRole('button', { name: /marquer valide|mark valid/i }).click(); + expect((await req).status(), 'mark-valid POST').toBeLessThan(300); + const state = await readDeliverable(deliverableId); + expect(state?.plagiarism_score, 'score cleared').toBeNull(); +}); + +test('admin can revoke a flagged deliverable via the danger dialog', async ({ page }) => { + const { deliverableId } = await seedFlaggedDeliverable(); + const row = await landOnFraudTab(page, deliverableId); + + await row.getByRole('button', { name: /^révoquer$|^revoke$/i }).click(); + await page.getByTestId('confirm-dangerous-reason').fill('E2E — proven plagiarism, revoke deliverable'); + + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/fraud/deliverables/${deliverableId}/revoke`) && r.request().method() === 'POST' + ); + await page.getByTestId('confirm-dangerous-action').click(); + expect((await req).status(), 'revoke POST').toBeLessThan(300); + const state = await readDeliverable(deliverableId); + expect(state?.revoked_at, 'revoked_at posé après révocation').not.toBeNull(); +}); diff --git a/e2e/admin/gdpr-guild.spec.ts b/e2e/admin/gdpr-guild.spec.ts new file mode 100644 index 0000000..93c274e --- /dev/null +++ b/e2e/admin/gdpr-guild.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from '@playwright/test'; +import { withDb, uniq, seedUser } from '../setup/db'; + +// Phase 3 — admin operations that mutate a specific user/entity: +// 1. GDPR export triggered from /users/[id] +// 2. Guild dissolve triggered from /operations +// +// Both are admin_destructive rate-limited; both require a valid target +// entity in the DB (user for GDPR, guild for dissolve). + +async function seedGuild(ownerId: string) { + const id = uniq(); + return withDb(async (client) => { + const { rows } = await client.query( + // La colonne s'appelle `founder_id` : `owner_id` n'existe pas. + // `tag` est NOT NULL — c'est le trigramme affiché à côté du nom. + `INSERT INTO guilds (name, slug, tag, founder_id, description) + VALUES ($1, $2, $3, $4, 'E2E test guild') + RETURNING id`, + [ + `E2E Guild ${id}`, + `e2e-guild-${id}`.slice(0, 60), + id.slice(0, 5).toUpperCase(), + ownerId + ] + ); + return { id: rows[0].id as string }; + }); +} + +test('admin triggers GDPR export from a user detail page', async ({ page }) => { + const victim = await seedUser({ prefix: 'gdpr' }); + + const detailLoad = page.waitForResponse( + (r) => r.url().includes(`/api/admin/users/${victim.id}`) && r.request().method() === 'GET' + ); + await page.goto(`/users/${victim.id}`); + await detailLoad; + + // GDPR trigger lives in a dedicated ``. Button label + // contains "GDPR" or "RGPD" per i18n. + const gdprBtn = page.getByRole('button', { name: /gdpr|rgpd|export/i }).first(); + await gdprBtn.scrollIntoViewIfNeeded(); + await expect(gdprBtn).toBeVisible(); + + const req = page.waitForResponse( + (r) => r.url().includes(`/admin/users/${victim.id}/gdpr-export`) && r.request().method() === 'POST' + ); + await gdprBtn.click(); + + // Some UIs require typing a reason in a confirm dialog — fill if present. + const reasonField = page.getByTestId('confirm-dangerous-reason'); + if (await reasonField.isVisible().catch(() => false)) { + await reasonField.fill('E2E — legitimate compliance drill'); + await page.getByTestId('confirm-dangerous-action').click(); + } + expect((await req).status(), 'gdpr-export POST').toBeLessThan(300); +}); + +test('admin dissolves a guild from /operations', async ({ page }) => { + const owner = await seedUser({ prefix: 'guildowner' }); + const guild = await seedGuild(owner.id); + + await page.goto('/operations'); + // La dissolution passe par un champ UUID puis un ConfirmDangerousDialog. + // Le champ n'a pas de