Skip to content

Commit d0443fc

Browse files
Merge branch 'alpha' into feat/issue-3281-remove-referent-name-filter
2 parents 45f3c43 + 35dd164 commit d0443fc

43 files changed

Lines changed: 828 additions & 620 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/src/app/api/upload/route.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { z } from "zod";
12
import { AUDIT_ACTIONS, type AuditActionKey } from "~/modules/audit";
23
import { getCurrentYear } from "~/modules/domain";
34
import { parseSiren } from "~/modules/shared/parseSiren";
@@ -42,6 +43,7 @@ function isFlowType(value: string | null): value is FlowType {
4243
* - 403 — declaration for current year not found (not owned by session SIREN)
4344
* - 400 — cse_opinion max files reached
4445
* - 422 — ClamAV detected a virus (body includes `virus` name)
46+
* - 499 — client closed the request before the upload finished
4547
* - 503 — ClamAV unreachable / timed out (transient, user should retry)
4648
* - 500 — S3 or DB failure after stream; compensating delete attempted
4749
*
@@ -195,6 +197,7 @@ export async function POST(request: Request): Promise<Response> {
195197
contentType,
196198
stream: request.body,
197199
flowType,
200+
signal: request.signal,
198201
});
199202
} catch (error) {
200203
console.error("[api/upload]", error);
@@ -223,11 +226,11 @@ export async function POST(request: Request): Promise<Response> {
223226
userId,
224227
userEmail,
225228
siren,
226-
metadata: {
229+
metadata: uploadAuditMetadataSchema.parse({
227230
flowType,
228231
fileId: result.fileId,
229232
fileName: result.fileName,
230-
},
233+
}),
231234
ipAddress: requestContext.ipAddress,
232235
userAgent: requestContext.userAgent,
233236
durationMs: Date.now() - startedAt,
@@ -296,11 +299,48 @@ function mapFailureToHttp(reason: PipelineFailureReason): {
296299
return { status: 422, errorMessage: "HTTP 422 virus_detected" };
297300
case "scan_unavailable":
298301
return { status: 503, errorMessage: "HTTP 503 antivirus_unavailable" };
302+
case "aborted":
303+
// 499 is the nginx-style "client closed request" status. The body is
304+
// never consumed by the client (they are gone), so the status is
305+
// purely for server-side observability.
306+
return { status: 499, errorMessage: "HTTP 499 client_aborted" };
299307
case "server_error":
300308
return { status: 500, errorMessage: "HTTP 500 server_error" };
301309
}
302310
}
303311

312+
/**
313+
* Strips control characters (including ANSI escape sequences) and clips to
314+
* 255 chars before a user-supplied string is persisted to the audit log or
315+
* echoed to a terminal via console.error. Defence-in-depth: a crafted header
316+
* could carry escape sequences that mislead log readers.
317+
*/
318+
function sanitizeUserText(value: string): string {
319+
let out = "";
320+
for (const ch of value) {
321+
const code = ch.codePointAt(0) ?? 0;
322+
if (code >= 0x20 && code !== 0x7f) out += ch;
323+
}
324+
return out.slice(0, 255);
325+
}
326+
327+
/**
328+
* Audit metadata schema for /api/upload. Fields sourced from user input
329+
* (fileName, virusName) use `sanitizedUserString` so future additions to the
330+
* schema are sanitised by construction — a new untrusted string just has to
331+
* reuse the same transform. Internal literals (flowType, fileId, s3Cleanup)
332+
* are already constrained and do not need sanitisation.
333+
*/
334+
const sanitizedUserString = z.string().transform(sanitizeUserText);
335+
336+
const uploadAuditMetadataSchema = z.object({
337+
flowType: z.enum(["cse_opinion", "joint_evaluation"]),
338+
fileId: z.string().optional(),
339+
fileName: sanitizedUserString.optional(),
340+
virusName: sanitizedUserString.optional(),
341+
s3Cleanup: z.enum(["ok", "failed"]).optional(),
342+
});
343+
304344
type AuditFailureInput = {
305345
action: AuditActionKey;
306346
flowType: FlowType;
@@ -330,11 +370,13 @@ function writeFailure({
330370
virusName = null,
331371
s3Cleanup = null,
332372
}: AuditFailureInput): void {
333-
const metadata: Record<string, unknown> = { flowType };
334-
if (fileName) metadata.fileName = fileName;
335-
if (fileId) metadata.fileId = fileId;
336-
if (virusName) metadata.virusName = virusName;
337-
if (s3Cleanup) metadata.s3Cleanup = s3Cleanup;
373+
const metadata = uploadAuditMetadataSchema.parse({
374+
flowType,
375+
fileName: fileName ?? undefined,
376+
fileId: fileId ?? undefined,
377+
virusName: virusName ?? undefined,
378+
s3Cleanup: s3Cleanup ?? undefined,
379+
});
338380

339381
void logAction({
340382
action,

packages/app/src/app/layout.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import Script from "next/script";
44
import { MatomoAnalytics } from "~/modules/analytics";
55
import { SessionProviderWrapper } from "~/modules/auth";
66
import {
7-
Footer,
87
Header,
98
ImpersonateBanner,
10-
ResourceBanner,
9+
PublicChrome,
1110
SkipLinks,
1211
} from "~/modules/layout";
1312
import { ProfileModal } from "~/modules/profile";
@@ -59,8 +58,7 @@ export default function RootLayout({
5958
<ImpersonateBanner />
6059
<Header />
6160
{children}
62-
<ResourceBanner />
63-
<Footer />
61+
<PublicChrome />
6462
<ProfileModal />
6563
</TRPCReactProvider>
6664
</SessionProviderWrapper>

packages/app/src/e2e/admin.e2e.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ test("admin user can access /admin and sees backoffice page", async ({
1010
await expect(page.getByText("administrateur")).toBeVisible();
1111
});
1212

13+
test("admin routes hide the public footer and help banner", async ({
14+
page,
15+
}) => {
16+
// Runs in the authenticated `chromium` Playwright project (see
17+
// playwright.config.ts): `page.goto("/admin")` reaches the real backoffice
18+
// page, so the assertions below exercise the PublicChrome branch, not a
19+
// login-redirect fallback that would trivially pass.
20+
await page.goto("/admin");
21+
await expect(
22+
page.getByRole("heading", { name: "Backoffice", level: 1 }),
23+
).toBeVisible();
24+
await expect(page.locator("footer#footer")).toHaveCount(0);
25+
await expect(
26+
page.getByRole("region", { name: "Ressources et aide" }),
27+
).toHaveCount(0);
28+
});
29+
1330
test("admin user can access /admin/impersonate and sees impersonate page", async ({
1431
page,
1532
}) => {
@@ -29,13 +46,10 @@ test("admin user can access /admin/parametres and sees settings page", async ({
2946
level: 1,
3047
}),
3148
).toBeVisible();
32-
await expect(
33-
page.getByRole("heading", { name: "Année de campagne active", level: 2 }),
34-
).toBeVisible();
3549
await expect(
3650
page.getByRole("heading", { name: "Échéances de campagne", level: 2 }),
3751
).toBeVisible();
3852
await expect(
39-
page.getByRole("spinbutton", { name: /année de campagne active/i }),
40-
).toBeVisible();
53+
page.getByRole("heading", { name: "Année de campagne active", level: 2 }),
54+
).not.toBeVisible();
4155
});

packages/app/src/e2e/public-referents.e2e.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,35 @@ test.describe("public referents search", () => {
6868
}
6969
});
7070

71+
test("landing on /referents without a filter shows the empty-filter hint and no results", async ({
72+
browser,
73+
}) => {
74+
const anonCtx = await browser.newContext({ storageState: undefined });
75+
try {
76+
const page = await anonCtx.newPage();
77+
await page.goto("/referents");
78+
await expect(
79+
page.getByText(/remplissez au moins un filtre/i),
80+
).toBeVisible();
81+
await expect(page.getByText("E2E Référent Paris")).not.toBeVisible();
82+
} finally {
83+
await anonCtx.close();
84+
}
85+
});
86+
87+
test("/referents shows the public help banner", async ({ browser }) => {
88+
const anonCtx = await browser.newContext({ storageState: undefined });
89+
try {
90+
const page = await anonCtx.newPage();
91+
await page.goto("/referents");
92+
await expect(
93+
page.getByRole("region", { name: /ressources et aide/i }),
94+
).toBeVisible();
95+
} finally {
96+
await anonCtx.close();
97+
}
98+
});
99+
71100
test("search by region filters the results", async ({ browser }) => {
72101
const anonCtx = await browser.newContext({ storageState: undefined });
73102
try {
@@ -100,6 +129,8 @@ test.describe("public referents search", () => {
100129
try {
101130
const page = await anonCtx.newPage();
102131
await page.goto("/referents");
132+
await page.getByLabel("Région").selectOption("11");
133+
await page.getByRole("button", { name: /^rechercher$/i }).click();
103134
await expect(page.getByText("E2E Référent Paris")).toBeVisible();
104135
await expect(page.getByText("e2e-paris@dreets.test")).not.toBeVisible();
105136
await expect(
@@ -117,6 +148,8 @@ test.describe("public referents search", () => {
117148
try {
118149
const page = await anonCtx.newPage();
119150
await page.goto("/referents");
151+
await page.getByLabel("Région").selectOption("11");
152+
await page.getByRole("button", { name: /^rechercher$/i }).click();
120153

121154
const row = page.locator("li", { hasText: "E2E Référent Paris" });
122155
await row.getByRole("link", { name: /voir le contact/i }).click();

packages/app/src/modules/admin/AdminNavigation.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
55

66
const adminLinks = [
77
{ href: "/admin", label: "Accueil" },
8+
{ href: "/admin/declarations", label: "Déclarations" },
89
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
910
{ href: "/admin/liste-referents", label: "Référents" },
1011
{ href: "/admin/parametres", label: "Paramètres" },

packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ describe("AdminNavigation", () => {
1919
it("renders all admin links", () => {
2020
render(<AdminNavigation />);
2121
expect(screen.getByRole("link", { name: "Accueil" })).toBeInTheDocument();
22+
expect(
23+
screen.getByRole("link", { name: "Déclarations" }),
24+
).toBeInTheDocument();
2225
expect(
2326
screen.getByRole("link", { name: "Mimoquer un Siren" }),
2427
).toBeInTheDocument();
@@ -61,4 +64,28 @@ describe("AdminNavigation", () => {
6164
"aria-current",
6265
);
6366
});
67+
68+
it("marks /admin/declarations as active — and not Accueil", () => {
69+
(usePathname as Mock).mockReturnValue("/admin/declarations");
70+
render(<AdminNavigation />);
71+
expect(screen.getByRole("link", { name: "Déclarations" })).toHaveAttribute(
72+
"aria-current",
73+
"page",
74+
);
75+
expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
76+
"aria-current",
77+
);
78+
});
79+
80+
it("marks /admin/declarations/<id> as active on the Déclarations link", () => {
81+
(usePathname as Mock).mockReturnValue("/admin/declarations/abc123");
82+
render(<AdminNavigation />);
83+
expect(screen.getByRole("link", { name: "Déclarations" })).toHaveAttribute(
84+
"aria-current",
85+
"page",
86+
);
87+
expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
88+
"aria-current",
89+
);
90+
});
6491
});

packages/app/src/modules/admin/declarations/AdminDeclarationsPage.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@ function DeclarationsContent() {
2222
dateFrom: searchParams.get("dateFrom") ?? undefined,
2323
dateTo: searchParams.get("dateTo") ?? undefined,
2424
status: (searchParams.get("status") as "draft" | "submitted") || undefined,
25-
index: searchParams.get("index")
26-
? Number(searchParams.get("index"))
27-
: undefined,
28-
indexOperator:
29-
(searchParams.get("indexOperator") as "gt" | "lt" | "eq") || undefined,
3025
page: Number(searchParams.get("page") ?? "1"),
3126
pageSize: Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)),
3227
sortBy: (searchParams.get("sortBy") as SortColumn) ?? "createdAt",

packages/app/src/modules/admin/declarations/SearchForm.tsx

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ export function SearchForm() {
2323
dateTo: searchParams.get("dateTo") ?? "",
2424
status:
2525
(searchParams.get("status") as "" | "draft" | "submitted") ?? "",
26-
index: searchParams.get("index") ?? "",
27-
indexOperator:
28-
(searchParams.get("indexOperator") as "" | "gt" | "lt" | "eq") ?? "",
2926
},
3027
},
3128
);
@@ -52,8 +49,6 @@ export function SearchForm() {
5249
dateFrom: "",
5350
dateTo: "",
5451
status: "",
55-
index: "",
56-
indexOperator: "",
5752
});
5853
router.push("/admin/declarations");
5954
}, [reset, router]);
@@ -144,42 +139,6 @@ export function SearchForm() {
144139
</select>
145140
</div>
146141
</div>
147-
<div className="fr-col-12 fr-col-md-3">
148-
<div className="fr-grid-row fr-grid-row--gutters">
149-
<div className="fr-col-6">
150-
<div className="fr-select-group">
151-
<label className="fr-label" htmlFor="search-index-op">
152-
Index
153-
</label>
154-
<select
155-
className="fr-select"
156-
id="search-index-op"
157-
{...register("indexOperator")}
158-
>
159-
<option value=""></option>
160-
<option value="eq">=</option>
161-
<option value="gt">&ge;</option>
162-
<option value="lt">&le;</option>
163-
</select>
164-
</div>
165-
</div>
166-
<div className="fr-col-6">
167-
<div className="fr-input-group">
168-
<label className="fr-label" htmlFor="search-index">
169-
Valeur
170-
</label>
171-
<input
172-
className="fr-input"
173-
id="search-index"
174-
max={100}
175-
min={0}
176-
type="number"
177-
{...register("index")}
178-
/>
179-
</div>
180-
</div>
181-
</div>
182-
</div>
183142
</div>
184143
<div className="fr-grid-row fr-grid-row--right fr-mt-2w">
185144
<ul className="fr-btns-group fr-btns-group--inline">

packages/app/src/modules/admin/declarations/__tests__/SearchForm.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vi.mock("next/navigation", async () => {
2020
import { SearchForm } from "../SearchForm";
2121

2222
describe("SearchForm", () => {
23-
it("renders all search fields", () => {
23+
it("renders all search fields and omits the removed Index / Valeur pair", () => {
2424
render(<SearchForm />);
2525

2626
expect(screen.getByLabelText("SIREN / Nom entreprise")).toBeInTheDocument();
@@ -29,8 +29,10 @@ describe("SearchForm", () => {
2929
expect(screen.getByLabelText("Date de dépôt (du)")).toBeInTheDocument();
3030
expect(screen.getByLabelText("Date de dépôt (au)")).toBeInTheDocument();
3131
expect(screen.getByLabelText("Statut")).toBeInTheDocument();
32-
expect(screen.getByLabelText("Index")).toBeInTheDocument();
33-
expect(screen.getByLabelText("Valeur")).toBeInTheDocument();
32+
// Regression guard for #3274 — keep these negative assertions next to
33+
// their positive counterparts so a future reintroduction is caught here.
34+
expect(screen.queryByLabelText("Index")).not.toBeInTheDocument();
35+
expect(screen.queryByLabelText("Valeur")).not.toBeInTheDocument();
3436
});
3537

3638
it("renders search and reset buttons", () => {

packages/app/src/modules/admin/declarations/__tests__/schemas.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ describe("searchDeclarationsSchema", () => {
2020
year: "2024",
2121
dateFrom: "2024-01-01",
2222
dateTo: "2024-12-31",
23-
index: "75",
24-
indexOperator: "gt",
2523
status: "submitted",
2624
page: "2",
2725
pageSize: "50",
@@ -35,8 +33,6 @@ describe("searchDeclarationsSchema", () => {
3533
year: 2024,
3634
dateFrom: "2024-01-01",
3735
dateTo: "2024-12-31",
38-
index: 75,
39-
indexOperator: "gt",
4036
status: "submitted",
4137
page: 2,
4238
pageSize: 50,

0 commit comments

Comments
 (0)