Skip to content

Commit ebab17f

Browse files
committed
Merge remote-tracking branch 'origin/alpha' into feat/issue-3186-public-referents
# Conflicts: # packages/app/src/server/audit/trpcMiddleware.ts
2 parents 43360aa + 335c465 commit ebab17f

60 files changed

Lines changed: 2372 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Add optional campaign milestone dates to the per-year campaign_deadline table.
2+
ALTER TABLE "app_campaign_deadline"
3+
ADD COLUMN IF NOT EXISTS "gip_publication_date" date;
4+
--> statement-breakpoint
5+
ALTER TABLE "app_campaign_deadline"
6+
ADD COLUMN IF NOT EXISTS "campaign_start_date" date;
7+
--> statement-breakpoint
8+
9+
-- Create the singleton global_setting table used for platform-wide variables
10+
-- (currently: the active campaign year). Access is gated by adminProcedure.
11+
CREATE TABLE IF NOT EXISTS "app_global_setting" (
12+
"id" integer PRIMARY KEY DEFAULT 1,
13+
"active_campaign_year" integer,
14+
"updated_at" timestamp with time zone NOT NULL DEFAULT now(),
15+
"updated_by" varchar(255) REFERENCES "app_user"("id") ON DELETE SET NULL
16+
);

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@
190190
"when": 1775800000000,
191191
"tag": "0026_add_referents_table",
192192
"breakpoints": true
193+
},
194+
{
195+
"idx": 27,
196+
"version": "7",
197+
"when": 1775900000000,
198+
"tag": "0027_add_global_settings",
199+
"breakpoints": true
193200
}
194201
]
195202
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { AdminSettingsPage } from "~/modules/admin/settings";
2+
3+
export default function Page() {
4+
return <AdminSettingsPage />;
5+
}

packages/app/src/app/aide/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const metadata: Metadata = {
88
"Retrouvez toutes les ressources pour comprendre et réaliser votre déclaration des indicateurs d'égalité professionnelle.",
99
};
1010

11+
// Reads live campaign settings from the DB (admins can change them anytime).
12+
export const dynamic = "force-dynamic";
13+
1114
export default function Page() {
1215
return <AidePage />;
1316
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,39 @@ describe("POST /api/upload", () => {
108108
);
109109
});
110110

111+
it("returns 403 and audits a failure row when the admin is impersonating", async () => {
112+
mocks.auth.mockResolvedValue({
113+
user: {
114+
id: "admin-1",
115+
email: "admin@example.com",
116+
siret: "12345678901234",
117+
isAdmin: true,
118+
impersonation: { siren: "987654321", name: "Acme" },
119+
},
120+
});
121+
122+
const { POST } = await import("../route");
123+
const response = await POST(
124+
buildRequest({
125+
"Content-Type": "application/pdf",
126+
"X-Filename": "f.pdf",
127+
"X-Flow-Type": "cse_opinion",
128+
}),
129+
);
130+
131+
expect(response.status).toBe(403);
132+
const body = await response.json();
133+
expect(body.error).toContain("mimoquage");
134+
expect(mocks.runUploadPipeline).not.toHaveBeenCalled();
135+
expect(mocks.logAction).toHaveBeenCalledWith(
136+
expect.objectContaining({
137+
action: "cse_opinion.upload_file",
138+
status: "failure",
139+
errorMessage: "HTTP 403 impersonation_read_only",
140+
}),
141+
);
142+
});
143+
111144
it("returns 400 when X-Filename is missing", async () => {
112145
validSession();
113146
const { POST } = await import("../route");

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ export async function POST(request: Request): Promise<Response> {
9393
const userId = session.user.id ?? null;
9494
const userEmail = session.user.email ?? null;
9595

96+
// Admin impersonation is a read-only support mode: file uploads are
97+
// refused server-side (UI hides the button) so the admin cannot write
98+
// files in the user's name (issue #3230).
99+
if (session.user.impersonation) {
100+
writeFailure({
101+
action,
102+
flowType,
103+
fileName: request.headers.get("x-filename"),
104+
fileId: null,
105+
errorMessage: "HTTP 403 impersonation_read_only",
106+
userId,
107+
userEmail,
108+
siren,
109+
requestContext,
110+
startedAt,
111+
});
112+
return Response.json(
113+
{
114+
error:
115+
"Mode mimoquage actif : l'envoi de fichier est désactivé en lecture seule.",
116+
},
117+
{ status: 403 },
118+
);
119+
}
120+
96121
const fileName = request.headers.get("x-filename");
97122
if (!fileName) {
98123
writeFailure({
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
import {
4+
ensureCurrentYearDeclaration,
5+
resetDeclarationToDraft,
6+
} from "./helpers/db";
7+
8+
const TEST_SIREN = "130025265";
9+
10+
test.describe("admin impersonation — read-only guards", () => {
11+
test.beforeEach(async () => {
12+
// A declaration row must exist before impersonation starts, because
13+
// `declaration.getOrCreate` blocks the insert branch during mimoquage
14+
// (issue #3230). The server component on step 1 calls getOrCreate on
15+
// every render and would otherwise throw FORBIDDEN.
16+
await ensureCurrentYearDeclaration();
17+
await resetDeclarationToDraft();
18+
});
19+
20+
test.afterEach(async ({ page }) => {
21+
// Best-effort stop of any impersonation left over so the next test
22+
// (or the next describe block) starts from a clean session.
23+
await page.goto("/admin/impersonate");
24+
const stopBtn = page.getByRole("button", { name: /arrêter le mimoquage/i });
25+
if (await stopBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
26+
await stopBtn.click();
27+
}
28+
});
29+
30+
test("form submit is disabled with a read-only tooltip during mimoquage", async ({
31+
page,
32+
}) => {
33+
await page.goto("/admin/impersonate");
34+
await page.getByLabel("SIREN de l'entreprise").fill(TEST_SIREN);
35+
await page.getByRole("button", { name: "Rechercher" }).click();
36+
await page.getByRole("button", { name: /valider et mimoquer/i }).click();
37+
38+
await page.waitForURL("**/mon-espace");
39+
await expect(page.getByText(/vous mimoquez l'entreprise/i)).toBeVisible();
40+
41+
await page.goto("/declaration-remuneration/etape/1");
42+
43+
const submitButton = page.getByRole("button", { name: /suivant/i });
44+
await expect(submitButton).toBeVisible();
45+
await expect(submitButton).toBeDisabled();
46+
const tooltipId = await submitButton.getAttribute("aria-describedby");
47+
expect(tooltipId).not.toBeNull();
48+
await expect(page.locator(`#${tooltipId}`)).toContainText(/mimoquage/i);
49+
});
50+
51+
test("file upload endpoint returns 403 during impersonation", async ({
52+
page,
53+
}) => {
54+
await page.goto("/admin/impersonate");
55+
await page.getByLabel("SIREN de l'entreprise").fill(TEST_SIREN);
56+
await page.getByRole("button", { name: "Rechercher" }).click();
57+
await page.getByRole("button", { name: /valider et mimoquer/i }).click();
58+
await page.waitForURL("**/mon-espace");
59+
60+
const response = await page.request.post("/api/upload", {
61+
headers: {
62+
"content-type": "application/pdf",
63+
"x-filename": "impersonated.pdf",
64+
"x-flow-type": "cse_opinion",
65+
},
66+
data: Buffer.from("%PDF-"),
67+
});
68+
expect(response.status()).toBe(403);
69+
const body = await response.json();
70+
expect(body.error).toMatch(/mimoquage/i);
71+
});
72+
});

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,24 @@ test("admin user can access /admin/impersonate and sees impersonate page", async
1818
page.getByRole("heading", { name: "Mimoquer une entreprise", level: 1 }),
1919
).toBeVisible();
2020
});
21+
22+
test("admin user can access /admin/parametres and sees settings page", async ({
23+
page,
24+
}) => {
25+
await page.goto("/admin/parametres");
26+
await expect(
27+
page.getByRole("heading", {
28+
name: "Paramètres de la plateforme",
29+
level: 1,
30+
}),
31+
).toBeVisible();
32+
await expect(
33+
page.getByRole("heading", { name: "Année de campagne active", level: 2 }),
34+
).toBeVisible();
35+
await expect(
36+
page.getByRole("heading", { name: "Échéances de campagne", level: 2 }),
37+
).toBeVisible();
38+
await expect(
39+
page.getByRole("spinbutton", { name: /année de campagne active/i }),
40+
).toBeVisible();
41+
});

packages/app/src/e2e/helpers/db.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,42 @@ function createConnection() {
88
return postgres(url, { max: 1 });
99
}
1010

11+
/**
12+
* Ensure a draft declaration row exists for the test SIREN and the current
13+
* year. Used by tests that can't rely on `getOrCreate` to lazily insert
14+
* the row — e.g. the admin-impersonation scenario, where the insert branch
15+
* is blocked server-side.
16+
*/
17+
export async function ensureCurrentYearDeclaration() {
18+
const sql = createConnection();
19+
try {
20+
const users = await sql`
21+
SELECT user_id FROM app_user_company WHERE siren = ${TEST_SIREN} LIMIT 1
22+
`;
23+
const userId = users[0]?.user_id as string | undefined;
24+
if (!userId) return;
25+
await sql`
26+
INSERT INTO app_declaration (
27+
id, siren, year, declarant_id, current_step, status,
28+
created_at, updated_at
29+
)
30+
VALUES (
31+
gen_random_uuid(),
32+
${TEST_SIREN},
33+
EXTRACT(YEAR FROM CURRENT_DATE)::int,
34+
${userId},
35+
1,
36+
'draft',
37+
NOW(),
38+
NOW()
39+
)
40+
ON CONFLICT ON CONSTRAINT declaration_siren_year_idx DO NOTHING
41+
`;
42+
} finally {
43+
await sql.end();
44+
}
45+
}
46+
1147
/**
1248
* Reset the test declaration to draft and clean all associated data
1349
* so a new full flow can be tested from scratch.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const adminLinks = [
77
{ href: "/admin", label: "Accueil" },
88
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
99
{ href: "/admin/liste-referents", label: "Référents" },
10+
{ href: "/admin/parametres", label: "Paramètres" },
1011
] as const;
1112

1213
export function AdminNavigation() {

0 commit comments

Comments
 (0)