Skip to content

Commit ae49c8f

Browse files
committed
fix: close CRLF injection in contact form; feat: add tests, CI, maintenance skills
- app/contact/actions.ts: reject control characters in `name` before interpolating it into the outbound email subject — same flaw already fixed in r-code/r-code-marketing - extract inline JSON-LD (layout.tsx, blog/[slug]/page.tsx) into src/lib/json-ld.ts so it's unit-testable - add Vitest (10 tests) and Playwright e2e smoke suite (9 tests) - add GitHub Actions CI (lint/typecheck/test/build/e2e), pnpm pinned - add 5 maintenance skills (doctor, check-launch, ship-release, verify, dependabot-sync), untracked — matches r-code-marketing/cleperformance
1 parent 8353357 commit ae49c8f

14 files changed

Lines changed: 1161 additions & 45 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: ci-${{ github.ref }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
lint:
17+
name: Lint
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v7
21+
- uses: pnpm/action-setup@v6
22+
with:
23+
version: 10.7.0
24+
- uses: actions/setup-node@v7
25+
with:
26+
node-version: 22
27+
cache: pnpm
28+
- run: pnpm install --frozen-lockfile
29+
- run: pnpm lint
30+
31+
typecheck:
32+
name: Type check
33+
runs-on: ubuntu-latest
34+
steps:
35+
- uses: actions/checkout@v7
36+
- uses: pnpm/action-setup@v6
37+
with:
38+
version: 10.7.0
39+
- uses: actions/setup-node@v7
40+
with:
41+
node-version: 22
42+
cache: pnpm
43+
- run: pnpm install --frozen-lockfile
44+
- run: pnpm typecheck
45+
46+
test:
47+
name: Unit tests
48+
runs-on: ubuntu-latest
49+
steps:
50+
- uses: actions/checkout@v7
51+
- uses: pnpm/action-setup@v6
52+
with:
53+
version: 10.7.0
54+
- uses: actions/setup-node@v7
55+
with:
56+
node-version: 22
57+
cache: pnpm
58+
- run: pnpm install --frozen-lockfile
59+
- run: pnpm test --run
60+
61+
build:
62+
name: Build
63+
runs-on: ubuntu-latest
64+
steps:
65+
- uses: actions/checkout@v7
66+
- uses: pnpm/action-setup@v6
67+
with:
68+
version: 10.7.0
69+
- uses: actions/setup-node@v7
70+
with:
71+
node-version: 22
72+
cache: pnpm
73+
- run: pnpm install --frozen-lockfile
74+
- run: pnpm build
75+
76+
e2e:
77+
name: E2E
78+
runs-on: ubuntu-latest
79+
needs: [lint, typecheck, test, build]
80+
steps:
81+
- uses: actions/checkout@v7
82+
83+
- uses: pnpm/action-setup@v6
84+
with:
85+
version: 10.7.0
86+
87+
- uses: actions/setup-node@v7
88+
with:
89+
node-version: 22
90+
cache: pnpm
91+
92+
- run: pnpm install --frozen-lockfile
93+
- run: pnpm exec playwright install --with-deps chromium
94+
- run: pnpm build
95+
- run: pnpm e2e
96+
env:
97+
CI: true

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
# testing
1414
/coverage
15+
/test-results
16+
/playwright-report
17+
/blob-report
1518

1619
# next.js
1720
/.next/

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [0.6.3] — 2026-08-06
4+
5+
### Corrections
6+
7+
- **Injection CRLF dans le formulaire de contact** — `app/contact/actions.ts` interpolait `name` tel quel dans le sujet de l'email sortant (`resend.emails.send`) sans filtrer les caractères de contrôle ; un `name` contenant `\r\n` pouvait injecter des en-têtes email arbitraires. Ajout d'un filtre `noControlChars`, même faille déjà corrigée dans `r-code`/`r-code-marketing`.
8+
9+
### Outillage
10+
11+
- **Tests unitaires (Vitest)** — 0 → 10 tests : `src/lib/__tests__/blog.test.ts` (tri par date, champs requis, slugs uniques, `getPost`/`formatDate`), `src/lib/__tests__/json-ld.test.ts`. Le JSON-LD, jusqu'ici inline dans `app/layout.tsx` et `app/blog/[slug]/page.tsx`, est extrait vers `src/lib/json-ld.ts` (`buildPersonSchema()`, `buildArticleSchema()`) pour être testable.
12+
- **Tests e2e (Playwright)** — `e2e/smoke.spec.ts` (9 tests) : accueil, navigation vers les 5 zones (projects/apps/lab/about/blog), formulaire de contact, page de blog, 404 brandée.
13+
- **CI GitHub Actions** (`.github/workflows/ci.yml`) — jobs `lint`/`typecheck`/`test`/`build` en parallèle puis `e2e`, `pnpm/action-setup` épinglé à `10.7.0` (jamais `latest`), `permissions: contents: read` et `concurrency`, patron `cleperformance`/`r-code-marketing`.
14+
- **`package.json`** — scripts `typecheck`, `test`, `e2e`, `check` ajoutés ; `packageManager: pnpm@10.7.0` épinglé pour que Corepack matche la CI.
15+
- **5 skills de maintenance** ajoutées sous `.claude/skills/` (non trackées, `.claude/` reste dans `.gitignore`) — `doctor`, `check-launch`, `ship-release`, `verify`, `dependabot-sync`, portées et adaptées depuis `r-code-marketing`.
16+
17+
---
18+
319
## [0.6.2] — 2026-08-02
420

521
### Corrections

‎app/blog/[slug]/page.tsx‎

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { formatDate, getPost, posts } from "@/lib/blog";
2+
import { buildArticleSchema } from "@/lib/json-ld";
23
import { ArrowLeft, Clock, ExternalLink } from "lucide-react";
34
import type { Metadata } from "next";
45
import Image from "next/image";
@@ -26,30 +27,13 @@ export default async function BlogPostPage({ params }: Props) {
2627
const post = getPost(slug);
2728
if (!post) notFound();
2829

29-
const articleSchema = {
30-
"@context": "https://schema.org",
31-
"@type": "Article",
32-
headline: post.title,
33-
description: post.description,
34-
datePublished: post.date,
35-
author: {
36-
"@type": "Person",
37-
name: "Randy Rimbault",
38-
url: "https://randy-code.dev",
39-
},
40-
publisher: {
41-
"@type": "Person",
42-
name: "Randy Rimbault",
43-
url: "https://randy-code.dev",
44-
},
45-
url: `https://randy-code.dev/blog/${post.slug}`,
46-
};
47-
4830
return (
4931
<main className="min-h-screen pb-20">
5032
<script
5133
type="application/ld+json"
52-
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
34+
dangerouslySetInnerHTML={{
35+
__html: JSON.stringify(buildArticleSchema(post)),
36+
}}
5337
/>
5438
{/* Hero image + overlay */}
5539
<div className="relative h-72 w-full overflow-hidden md:h-96">

‎app/contact/actions.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { Resend } from "resend";
44

55
const resend = new Resend(process.env.RESEND_API_KEY);
66

7+
// `name` is interpolated into the outbound email's Subject header below —
8+
// CRLF in that value could inject extra headers. `email` is already safe
9+
// (its regex below has no \s allowance).
10+
const noControlChars = /^[^\r\n\0]*$/;
11+
712
export interface ContactState {
813
success?: boolean;
914
error?: string;
@@ -21,6 +26,10 @@ export async function sendContact(
2126
return { error: "Tous les champs sont obligatoires." };
2227
}
2328

29+
if (!noControlChars.test(name)) {
30+
return { error: "Caractères invalides dans le nom." };
31+
}
32+
2433
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
2534
return { error: "Adresse email invalide." };
2635
}

‎app/layout.tsx‎

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { buildPersonSchema } from "@/lib/json-ld";
12
import type { Metadata, Viewport } from "next";
23
import { Geist, Geist_Mono } from "next/font/google";
34
import ServiceWorkerRegistration from "./components/service-worker-registration";
@@ -35,25 +36,6 @@ export const metadata: Metadata = {
3536
},
3637
};
3738

38-
const personSchema = {
39-
"@context": "https://schema.org",
40-
"@type": "Person",
41-
name: "Randy Rimbault",
42-
jobTitle: "Développeur Fullstack Freelance",
43-
url: "https://randy-code.dev",
44-
description:
45-
"Développeur fullstack freelance spécialisé TypeScript / Next.js. Sites vitrines, applications SaaS, SEO local.",
46-
knowsAbout: [
47-
"TypeScript",
48-
"Next.js",
49-
"SEO local",
50-
"SaaS",
51-
"Développement web",
52-
"React",
53-
"Prisma",
54-
],
55-
};
56-
5739
export default function RootLayout({
5840
children,
5941
}: Readonly<{
@@ -67,7 +49,9 @@ export default function RootLayout({
6749
<body className="min-h-full flex flex-col bg-background text-foreground">
6850
<script
6951
type="application/ld+json"
70-
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
52+
dangerouslySetInnerHTML={{
53+
__html: JSON.stringify(buildPersonSchema()),
54+
}}
7155
/>
7256
<ServiceWorkerRegistration />
7357
{children}

‎e2e/smoke.spec.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("home page loads with title", async ({ page }) => {
4+
await page.goto("/");
5+
6+
await expect(page).toHaveTitle(/./);
7+
await expect(page.locator("h1").first()).toBeVisible();
8+
});
9+
10+
for (const route of ["/projects", "/apps", "/lab", "/about", "/blog"]) {
11+
test(`${route} loads with a visible title`, async ({ page }) => {
12+
await page.goto(route);
13+
await expect(page.locator("h1").first()).toBeVisible();
14+
});
15+
}
16+
17+
test("about page has a working contact form", async ({ page }) => {
18+
await page.goto("/about");
19+
20+
await expect(page.locator('input[name="name"]')).toBeVisible();
21+
await expect(page.locator('input[name="email"]')).toBeVisible();
22+
await expect(page.locator('textarea[name="message"]')).toBeVisible();
23+
await expect(page.getByRole("button", { name: /envoyer/i })).toBeVisible();
24+
});
25+
26+
test("a blog post page renders", async ({ page }) => {
27+
await page.goto("/blog/liflow-refonte-souvenirs-familiaux");
28+
29+
await expect(page.locator("h1")).toBeVisible();
30+
// 2 scripts: the root layout's Person schema + this page's Article schema.
31+
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(
32+
2,
33+
);
34+
});
35+
36+
test("404 page is branded", async ({ page }) => {
37+
await page.goto("/page-qui-nexiste-pas");
38+
39+
await expect(page.getByText("404")).toBeVisible();
40+
await expect(
41+
page.getByRole("link", { name: /retour à la carte/i }),
42+
).toBeVisible();
43+
});

‎package.json‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
{
22
"name": "randy-code",
3-
"version": "0.6.2",
3+
"version": "0.6.3",
44
"private": true,
5+
"packageManager": "pnpm@10.7.0",
56
"scripts": {
67
"dev": "next dev",
78
"build": "next build",
89
"start": "next start",
9-
"lint": "eslint"
10+
"lint": "eslint",
11+
"typecheck": "tsc --noEmit",
12+
"test": "vitest",
13+
"e2e": "playwright test",
14+
"check": "pnpm lint && pnpm typecheck && pnpm build"
1015
},
1116
"dependencies": {
1217
"class-variance-authority": "^0.7.1",
@@ -23,13 +28,15 @@
2328
"tw-animate-css": "^1.4.0"
2429
},
2530
"devDependencies": {
31+
"@playwright/test": "^1.59.1",
2632
"@tailwindcss/postcss": "^4.3.3",
2733
"@types/node": "^26.1.2",
2834
"@types/react": "^19.2.18",
2935
"@types/react-dom": "^19.2.4",
3036
"eslint": "^9.39.5",
3137
"eslint-config-next": "16.2.12",
3238
"tailwindcss": "^4.3.3",
33-
"typescript": "^6.0.3"
39+
"typescript": "^6.0.3",
40+
"vitest": "^4.1.5"
3441
}
3542
}

‎playwright.config.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./e2e",
5+
fullyParallel: true,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 1 : 0,
8+
reporter: "line",
9+
use: {
10+
baseURL: "http://localhost:3000",
11+
trace: "on-first-retry",
12+
},
13+
projects: [
14+
{
15+
name: "chromium",
16+
use: { ...devices["Desktop Chrome"] },
17+
},
18+
],
19+
webServer: {
20+
command: "pnpm start",
21+
url: "http://localhost:3000",
22+
reuseExistingServer: !process.env.CI,
23+
},
24+
});

0 commit comments

Comments
 (0)