Skip to content

Commit 932d0ad

Browse files
committed
Merge remote-tracking branch 'origin/alpha' into fix/my-space-side-panel
# Conflicts: # packages/app/src/modules/my-space/VerticalStepper.tsx
2 parents 19c0d2d + 35dd164 commit 932d0ad

77 files changed

Lines changed: 1514 additions & 734 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Track when a CSE opinion deposit (avis CSE) is finalized by the declarant.
2+
-- A NULL value means the flow is still in progress. The side panel uses this
3+
-- column (rather than "any cseOpinions row exists") to decide whether the
4+
-- declaration should be shown as "clôturée".
5+
ALTER TABLE "app_declaration"
6+
ADD COLUMN IF NOT EXISTS "cse_opinion_completed_at" timestamp with time zone;

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@
197197
"when": 1775900000000,
198198
"tag": "0027_add_global_settings",
199199
"breakpoints": true
200+
},
201+
{
202+
"idx": 28,
203+
"version": "7",
204+
"when": 1776000000000,
205+
"tag": "0028_add_cse_opinion_completed_at",
206+
"breakpoints": true
200207
}
201208
]
202209
}

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/avis-cse/layout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { redirect } from "next/navigation";
22
import { CseOpinionLayout } from "~/modules/cseOpinion";
3-
import { extractSiren } from "~/modules/my-space";
43
import { auth } from "~/server/auth";
4+
import { getEffectiveSiren } from "~/server/auth/companyAccess";
55
import { api } from "~/trpc/server";
66

77
export default async function CseOpinionRootLayout({
@@ -15,12 +15,11 @@ export default async function CseOpinionRootLayout({
1515
redirect("/login");
1616
}
1717

18-
const siret = session.user.siret;
19-
if (!siret) {
18+
const siren = getEffectiveSiren(session);
19+
if (!siren) {
2020
redirect("/");
2121
}
2222

23-
const siren = extractSiren(siret);
2423
const [company, declarationData] = await Promise.all([
2524
api.company.get({ siren }),
2625
api.declaration.getOrCreate(),

packages/app/src/app/declaration-remuneration/layout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {
33
DeclarationLayout,
44
MissingSiret,
55
} from "~/modules/declaration-remuneration";
6-
import { extractSiren } from "~/modules/domain";
76
import { auth } from "~/server/auth";
7+
import { getEffectiveSiren } from "~/server/auth/companyAccess";
88
import { api } from "~/trpc/server";
99

1010
export default async function DeclarationRootLayout({
@@ -18,12 +18,11 @@ export default async function DeclarationRootLayout({
1818
redirect("/login");
1919
}
2020

21-
const siret = session.user.siret;
22-
if (!siret) {
21+
const siren = getEffectiveSiren(session);
22+
if (!siren) {
2323
return <MissingSiret />;
2424
}
2525

26-
const siren = extractSiren(siret);
2726
const [company, declarationData] = await Promise.all([
2827
api.company.get({ siren }),
2928
api.declaration.getOrCreate(),

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/app/mon-espace/page.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { redirect } from "next/navigation";
22

33
import { MonEspacePage } from "~/modules/my-space";
44
import { auth } from "~/server/auth";
5-
import { HydrateClient } from "~/trpc/server";
5+
import { getEffectiveSiren } from "~/server/auth/companyAccess";
6+
import { api, HydrateClient } from "~/trpc/server";
67

78
export default async function Page() {
89
const session = await auth();
@@ -11,18 +12,24 @@ export default async function Page() {
1112
redirect("/login");
1213
}
1314

14-
// When an admin is impersonating a company, use the impersonated SIREN
15-
// (padded to SIRET length so MonEspacePage can extract it the same way).
16-
const effectiveSiret =
17-
session.user.isAdmin && session.user.impersonation
18-
? session.user.impersonation.siren
19-
: (session.user.siret ?? null);
15+
// When an admin is impersonating a company, use the impersonated SIREN.
16+
// MonEspacePage expects a SIRET-length string for symmetric handling;
17+
// passing the SIREN (9 chars) is fine since it only reads the first 9.
18+
const effectiveSiret = getEffectiveSiren(session);
19+
20+
// The JWT only captures `phone` at sign-in, so `session.user.phone` goes
21+
// stale as soon as the user saves a phone through the missing-info modal
22+
// and re-triggers it on every render. Reading from the profile table on
23+
// each page load keeps the missing-info gate honest. Fall back to `null`
24+
// when the profile row is missing so the page still renders (the modal
25+
// will prompt for a phone number just like before).
26+
const profile = await api.profile.get().catch(() => null);
2027

2128
return (
2229
<HydrateClient>
2330
<MonEspacePage
2431
siret={effectiveSiret}
25-
userPhone={session.user.phone ?? null}
32+
userPhone={profile?.phone ?? null}
2633
/>
2734
</HydrateClient>
2835
);

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/campaign-deadlines-gating.e2e.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,20 @@ test.describe("Campaign deadlines gating", () => {
4545
// shared auth.setup project handles.
4646
async function seedSubmittedCompliance() {
4747
await resetDeclarationToDraft();
48-
await setCompanyHasCse(true);
49-
await setUserPhone("0122334455");
5048
await setDeclarationComplianceState({
5149
status: "submitted",
5250
currentStep: 6,
5351
compliancePath: "corrective_action",
5452
});
5553
}
5654

55+
// Phone + CSE flags must be set before login so the JWT picks them up and
56+
// the missing-info-modal does not intercept clicks on /mon-espace.
57+
async function seedUserProfile() {
58+
await setUserPhone("0122334455");
59+
await setCompanyHasCse(true);
60+
}
61+
5762
test.afterAll(async () => {
5863
await deleteCampaignDeadlines(testDeclarationYear);
5964
await resetDeclarationToDraft();
@@ -68,6 +73,7 @@ test.describe("Campaign deadlines gating", () => {
6873
test("panel shows Modifier link and 'Modifiable jusqu'au' text", async ({
6974
page,
7075
}) => {
76+
await seedUserProfile();
7177
await page.context().clearCookies();
7278
await loginWithProConnect(page);
7379
// The declaration row is only created by getOrCreate() when visiting a
@@ -99,6 +105,7 @@ test.describe("Campaign deadlines gating", () => {
99105
test("submitted declaration can re-enter a non-recap step", async ({
100106
page,
101107
}) => {
108+
await seedUserProfile();
102109
await page.context().clearCookies();
103110
await loginWithProConnect(page);
104111
await page.goto("/declaration-remuneration");
@@ -117,6 +124,7 @@ test.describe("Campaign deadlines gating", () => {
117124
test("panel hides Modifier link and shows 'Modification close depuis'", async ({
118125
page,
119126
}) => {
127+
await seedUserProfile();
120128
await page.context().clearCookies();
121129
await loginWithProConnect(page);
122130
await page.goto("/declaration-remuneration");
@@ -143,6 +151,7 @@ test.describe("Campaign deadlines gating", () => {
143151
test("submitted declaration non-recap step redirects to recap", async ({
144152
page,
145153
}) => {
154+
await seedUserProfile();
146155
await page.context().clearCookies();
147156
await loginWithProConnect(page);
148157
await page.goto("/declaration-remuneration");

packages/app/src/e2e/declaration-process-panel.e2e.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,46 @@ test.describe("Declaration process panel", () => {
184184
});
185185
});
186186

187+
test.describe("Variant: cse with opinions saved but not finalized", () => {
188+
test.beforeAll(async () => {
189+
await setDeclarationComplianceState({
190+
compliancePath: "joint_evaluation",
191+
complianceCompletedAt: new Date(),
192+
cseOpinionCompletedAt: null,
193+
});
194+
await insertJointEvaluationFile(CURRENT_YEAR);
195+
await insertCseOpinion(CURRENT_YEAR);
196+
});
197+
198+
test("keeps CSE step current while finalization has not been done", async ({
199+
page,
200+
}) => {
201+
await page.context().clearCookies();
202+
await loginWithProConnect(page);
203+
await waitForDsfrModal(page, PANEL_ID);
204+
205+
const panel = page.locator(`#${PANEL_ID}`);
206+
const remuButton = page.getByRole("button", { name: "Rémunération" });
207+
await expect(remuButton.first()).toBeVisible();
208+
await clickAndExpectDialogOpen(page, remuButton.first(), PANEL_ID);
209+
210+
await expect(panel.getByText("Démarche close")).not.toBeVisible();
211+
await expect(
212+
panel.getByText("Déposer le ou les avis du CSE"),
213+
).toBeVisible();
214+
const ctaLink = panel.getByRole("link", {
215+
name: "Continuer la déclaration",
216+
});
217+
await expect(ctaLink).toHaveAttribute("href", /avis-cse/);
218+
});
219+
});
220+
187221
test.describe("Variant: closed (compliance completed + CSE deposited)", () => {
188222
test.beforeAll(async () => {
189223
await setDeclarationComplianceState({
190224
compliancePath: "joint_evaluation",
191225
complianceCompletedAt: new Date(),
226+
cseOpinionCompletedAt: new Date(),
192227
});
193228
await insertJointEvaluationFile(CURRENT_YEAR);
194229
await insertCseOpinion(CURRENT_YEAR);
@@ -209,7 +244,7 @@ test.describe("Declaration process panel", () => {
209244
await expect(panel.getByText("Démarche close")).toBeVisible();
210245
await expect(
211246
panel.getByText(
212-
"Cette démarche est terminée, aucune modification n'est possible.",
247+
"Cette démarche est terminée. Les avis du CSE restent modifiables jusqu'à l'échéance.",
213248
),
214249
).toBeVisible();
215250
});

0 commit comments

Comments
 (0)