portail-associations/server/operationalTour.ts

952 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from "zod";
import type { Request } from "express";
import { COOKIE_NAME } from "@shared/const";
import type { User } from "../drizzle/schema";
import { createSessionToken, loginLocalUser } from "./_core/auth";
import * as db from "./db";
export const OPERATIONAL_TOUR_SETTINGS_KEY = "system.operationalTour.settings";
export const OPERATIONAL_TOUR_HISTORY_KEY = "system.operationalTour.history";
export const operationalTourEnvironmentSchema = z.enum(["production", "secondary"]);
export type OperationalTourEnvironment = z.infer<typeof operationalTourEnvironmentSchema>;
export const operationalTourAuthModeSchema = z.enum(["local", "oauth"]);
export type OperationalTourAuthMode = z.infer<typeof operationalTourAuthModeSchema>;
export const operationalTourStepKindSchema = z.enum(["technical", "user"]);
export type OperationalTourStepKind = z.infer<typeof operationalTourStepKindSchema>;
const operationalTourTargetSchema = z.object({
baseUrl: z.string().trim().optional().default(""),
associationEmail: z.string().trim().optional().default(""),
associationPassword: z.string().trim().optional().default(""),
associationAuthMode: operationalTourAuthModeSchema.optional().default("local"),
accueilEmail: z.string().trim().optional().default(""),
accueilPassword: z.string().trim().optional().default(""),
accueilAuthMode: operationalTourAuthModeSchema.optional().default("local"),
adminEmail: z.string().trim().optional().default(""),
adminPassword: z.string().trim().optional().default(""),
adminAuthMode: operationalTourAuthModeSchema.optional().default("local"),
});
const EMPTY_TARGET = {
baseUrl: "",
associationEmail: "",
associationPassword: "",
associationAuthMode: "local",
accueilEmail: "",
accueilPassword: "",
accueilAuthMode: "local",
adminEmail: "",
adminPassword: "",
adminAuthMode: "local",
} as const;
const operationalTourSettingsSchema = z.object({
defaultEnvironment: operationalTourEnvironmentSchema.default("production"),
targets: z.object({
production: operationalTourTargetSchema.default(EMPTY_TARGET),
secondary: operationalTourTargetSchema.default(EMPTY_TARGET),
}),
});
export type OperationalTourSettings = z.infer<typeof operationalTourSettingsSchema>;
const operationalTourStepSchema = z.object({
id: z.string(),
label: z.string(),
kind: operationalTourStepKindSchema.default("technical"),
status: z.enum(["ok", "warning", "error"]),
durationMs: z.number().int().nonnegative(),
message: z.string(),
details: z.string().optional(),
url: z.string().optional(),
critical: z.boolean().default(true),
});
export type OperationalTourStep = z.infer<typeof operationalTourStepSchema>;
const operationalTourReportSchema = z.object({
id: z.string(),
environment: operationalTourEnvironmentSchema,
environmentLabel: z.string(),
baseUrl: z.string(),
status: z.enum(["ok", "warning", "error"]),
startedAt: z.string(),
finishedAt: z.string(),
durationMs: z.number().int().nonnegative(),
initiatedBy: z.object({
userId: z.number().int().positive(),
name: z.string(),
email: z.string().optional(),
}),
summary: z.object({
total: z.number().int().nonnegative(),
ok: z.number().int().nonnegative(),
warning: z.number().int().nonnegative(),
error: z.number().int().nonnegative(),
}),
steps: z.array(operationalTourStepSchema),
});
export type OperationalTourReport = z.infer<typeof operationalTourReportSchema>;
const OPERATIONAL_TOUR_HISTORY_MAX_BYTES = 60_000;
const OPERATIONAL_TOUR_HISTORY_MAX_REPORTS = 12;
const OPERATIONAL_TOUR_DUPLICATE_WINDOW_MS = 15_000;
const EMPTY_SETTINGS: OperationalTourSettings = {
defaultEnvironment: "production",
targets: {
production: {
baseUrl: "https://www.portail-association973.com",
associationEmail: "",
associationPassword: "",
associationAuthMode: "local",
accueilEmail: "",
accueilPassword: "",
accueilAuthMode: "local",
adminEmail: "",
adminPassword: "",
adminAuthMode: "local",
},
secondary: {
baseUrl: "",
associationEmail: "",
associationPassword: "",
associationAuthMode: "local",
accueilEmail: "",
accueilPassword: "",
accueilAuthMode: "local",
adminEmail: "",
adminPassword: "",
adminAuthMode: "local",
},
},
};
type PageCheckOptions = {
id: string;
label: string;
kind: OperationalTourStepKind;
url: string;
cookieHeader?: string | null;
markers?: string[];
expectedPath?: string;
critical?: boolean;
};
type PageCheckResult = OperationalTourStep;
function normalizeBaseUrl(value: string | undefined) {
return String(value || "").trim().replace(/\/+$/, "");
}
function normalizeEmail(value: string | undefined) {
return String(value || "").trim().toLowerCase();
}
function buildRequestOrigin(req: Request) {
const forwardedProto = typeof req.headers["x-forwarded-proto"] === "string" ? req.headers["x-forwarded-proto"] : undefined;
const forwardedHost = typeof req.headers["x-forwarded-host"] === "string" ? req.headers["x-forwarded-host"] : undefined;
const protocol = forwardedProto || req.protocol || "http";
const host = forwardedHost || req.get?.("host") || "";
return host ? `${protocol}://${host}` : "";
}
function isLoginPage(body: string, finalUrl: string) {
return finalUrl.includes("/login") || body.includes("Connexion requise") || body.includes("Se connecter");
}
function normalizePathname(url: string) {
try {
const pathname = new URL(url).pathname || "/";
return pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
} catch {
return url;
}
}
function hasReactShell(body: string, contentType: string | null) {
const lowerBody = body.toLowerCase();
const lowerType = String(contentType || "").toLowerCase();
return (
lowerType.includes("text/html") &&
(lowerBody.includes("<!doctype html") || lowerBody.includes("<html")) &&
(lowerBody.includes("<title>") || lowerBody.includes("script type=\"module\"") || lowerBody.includes("id=\"manus-runtime\""))
);
}
function buildStaticStep(input: Omit<OperationalTourStep, "durationMs"> & { durationMs?: number }): OperationalTourStep {
return {
...input,
durationMs: input.durationMs ?? 0,
};
}
async function fetchPageCheck(options: PageCheckOptions): Promise<PageCheckResult> {
const started = Date.now();
try {
const response = await fetch(options.url, {
headers: options.cookieHeader ? { cookie: options.cookieHeader } : undefined,
redirect: "follow",
});
const body = await response.text();
const finalUrl = response.url || options.url;
const contentType = response.headers.get("content-type");
const expectedPath = normalizePathname(options.expectedPath || options.url);
const finalPath = normalizePathname(finalUrl);
const markers = options.markers || [];
const matchedMarker = markers.find((marker) => body.includes(marker));
const routeMatches = expectedPath === "/" ? finalPath === "/" : finalPath === expectedPath || finalPath.startsWith(`${expectedPath}/`);
const reactShell = hasReactShell(body, contentType);
if (!response.ok) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
durationMs: Date.now() - started,
message: `HTTP ${response.status}`,
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
if (options.cookieHeader && isLoginPage(body, finalUrl)) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
durationMs: Date.now() - started,
message: "Authentification non reconnue",
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
if (!routeMatches) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "warning",
durationMs: Date.now() - started,
message: "Page servie sur un chemin inattendu",
details: `${finalUrl} (attendu: ${expectedPath})`,
url: options.url,
critical: options.critical ?? true,
};
}
if (matchedMarker) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "ok",
durationMs: Date.now() - started,
message: `Contrôle réussi (${matchedMarker})`,
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
if (reactShell) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "ok",
durationMs: Date.now() - started,
message: "Page accessible, rendu client servi",
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
}
return {
id: options.id,
label: options.label,
kind: options.kind,
status: markers.length > 0 ? "warning" : "ok",
durationMs: Date.now() - started,
message: markers.length > 0 ? "Page accessible, marqueur visuel non confirmé" : "Contrôle réussi",
details: finalUrl,
url: options.url,
critical: options.critical ?? true,
};
} catch (error) {
return {
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
durationMs: Date.now() - started,
message: error instanceof Error ? error.message : "Erreur réseau",
url: options.url,
critical: options.critical ?? true,
};
}
}
async function fetchJsonCheck(options: {
id: string;
label: string;
kind: OperationalTourStepKind;
url: string;
cookieHeader?: string | null;
markers?: string[];
critical?: boolean;
}) {
const started = Date.now();
try {
const response = await fetch(options.url, {
headers: {
accept: "application/json",
...(options.cookieHeader ? { cookie: options.cookieHeader } : {}),
},
redirect: "follow",
});
const body = await response.text();
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const matchesMarker = (options.markers || []).some((marker) => body.includes(marker));
if (!response.ok) {
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
message: `HTTP ${response.status}`,
details: response.url || options.url,
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
}
if (!contentType.includes("application/json")) {
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: "warning",
message: "Réponse non JSON",
details: response.url || options.url,
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
}
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: matchesMarker || !(options.markers || []).length ? "ok" : "warning",
message: matchesMarker || !(options.markers || []).length ? "API accessible et réponse JSON valide" : "API accessible, signature JSON à confirmer",
details: response.url || options.url,
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
} catch (error) {
return buildStaticStep({
id: options.id,
label: options.label,
kind: options.kind,
status: "error",
message: error instanceof Error ? error.message : "Erreur réseau",
url: options.url,
critical: options.critical ?? true,
durationMs: Date.now() - started,
});
}
}
async function fetchHomeDiagnostics(baseUrl: string) {
const started = Date.now();
try {
const response = await fetch(`${baseUrl}/`, { redirect: "follow" });
const body = await response.text();
return {
durationMs: Date.now() - started,
body,
headers: response.headers,
ok: response.ok,
finalUrl: response.url || `${baseUrl}/`,
};
} catch (error) {
return {
durationMs: Date.now() - started,
body: "",
headers: new Headers(),
ok: false,
finalUrl: `${baseUrl}/`,
error: error instanceof Error ? error.message : "Erreur réseau",
};
}
}
async function createConfiguredSessionCookie(email: string, password?: string) {
const normalizedEmail = normalizeEmail(email);
const normalizedPassword = String(password || "");
let user;
if (normalizedPassword) {
user = await loginLocalUser({ email: normalizedEmail, password: normalizedPassword });
} else {
const existingUser = await db.getUserByEmail(normalizedEmail);
if (!existingUser) {
throw new Error("Compte introuvable");
}
if (!existingUser.isActive) {
throw new Error("Compte inactif");
}
if (!existingUser.loginMethod || !existingUser.loginMethod.endsWith("_oauth")) {
throw new Error("Mot de passe requis pour ce compte");
}
user = existingUser;
}
const token = await createSessionToken(user);
return `${COOKIE_NAME}=${token}`;
}
async function tryFindPublicProfileUrl(baseUrl: string) {
try {
const response = await fetch(`${baseUrl}/associations`, { redirect: "follow" });
if (!response.ok) return null;
const body = await response.text();
const match = body.match(/href="(\/associations\/[^"]+)"/);
if (match) return `${baseUrl}${match[1]}`;
} catch {
// fallback below
}
try {
const directory = await db.listAssociationDirectoryMapEntries({ limit: 1 });
const firstEntry = directory.data?.[0];
return firstEntry?.id ? `${baseUrl}/associations/${firstEntry.id}` : null;
} catch {
return null;
}
}
function computeReportStatus(steps: OperationalTourStep[]): OperationalTourReport["status"] {
const hasCriticalError = steps.some((step) => step.critical !== false && step.status === "error");
if (hasCriticalError) return "error";
const hasWarning = steps.some((step) => step.status !== "ok");
return hasWarning ? "warning" : "ok";
}
function summarizeSteps(steps: OperationalTourStep[]) {
return {
total: steps.length,
ok: steps.filter((step) => step.status === "ok").length,
warning: steps.filter((step) => step.status === "warning").length,
error: steps.filter((step) => step.status === "error").length,
};
}
function summarizeStepsByKind(steps: OperationalTourStep[], kind: OperationalTourStepKind) {
return summarizeSteps(steps.filter((step) => step.kind === kind));
}
export function sanitizeOperationalTourSettings(input?: unknown): OperationalTourSettings {
const parsed = operationalTourSettingsSchema.safeParse(input);
if (!parsed.success) return EMPTY_SETTINGS;
return {
defaultEnvironment: parsed.data.defaultEnvironment,
targets: {
production: {
...EMPTY_SETTINGS.targets.production,
...parsed.data.targets.production,
baseUrl: normalizeBaseUrl(parsed.data.targets.production.baseUrl) || EMPTY_SETTINGS.targets.production.baseUrl,
associationAuthMode: parsed.data.targets.production.associationAuthMode || "local",
accueilAuthMode: parsed.data.targets.production.accueilAuthMode || "local",
adminAuthMode: parsed.data.targets.production.adminAuthMode || "local",
},
secondary: {
...EMPTY_SETTINGS.targets.secondary,
...parsed.data.targets.secondary,
baseUrl: normalizeBaseUrl(parsed.data.targets.secondary.baseUrl),
associationAuthMode: parsed.data.targets.secondary.associationAuthMode || "local",
accueilAuthMode: parsed.data.targets.secondary.accueilAuthMode || "local",
adminAuthMode: parsed.data.targets.secondary.adminAuthMode || "local",
},
},
};
}
export async function getOperationalTourSettings() {
const raw = await db.getPortalSetting(OPERATIONAL_TOUR_SETTINGS_KEY);
if (!raw) return EMPTY_SETTINGS;
try {
return sanitizeOperationalTourSettings(JSON.parse(raw));
} catch {
return EMPTY_SETTINGS;
}
}
export async function saveOperationalTourSettings(settings: OperationalTourSettings) {
const sanitized = sanitizeOperationalTourSettings(settings);
await db.setPortalSetting(
OPERATIONAL_TOUR_SETTINGS_KEY,
JSON.stringify(sanitized),
"Configuration du tour opérationnel du site"
);
return sanitized;
}
export async function getOperationalTourHistory() {
const raw = await db.getPortalSetting(OPERATIONAL_TOUR_HISTORY_KEY);
if (!raw) return [] as OperationalTourReport[];
try {
const parsed = z.array(operationalTourReportSchema).parse(JSON.parse(raw));
return parsed;
} catch {
return [] as OperationalTourReport[];
}
}
function compactOperationalTourHistory(history: OperationalTourReport[]) {
const compactStep = (step: OperationalTourStep): OperationalTourStep => ({
id: step.id,
label: step.label,
kind: step.kind,
status: step.status,
durationMs: step.durationMs,
message: step.message,
critical: step.critical ?? true,
...(step.status !== "ok" && step.details ? { details: step.details } : {}),
...(step.status !== "ok" && step.url ? { url: step.url } : {}),
});
const buildSignature = (report: OperationalTourReport) => JSON.stringify({
environment: report.environment,
baseUrl: report.baseUrl,
status: report.status,
summary: report.summary,
steps: report.steps.map((step) => ({
id: step.id,
kind: step.kind,
status: step.status,
message: step.message,
details: step.details || "",
})),
});
const dedupedHistory: OperationalTourReport[] = [];
for (const report of history) {
const previous = dedupedHistory[dedupedHistory.length - 1];
if (previous && previous.environment === report.environment) {
const previousTime = new Date(previous.finishedAt).getTime();
const currentTime = new Date(report.finishedAt).getTime();
if (
Number.isFinite(previousTime) &&
Number.isFinite(currentTime) &&
Math.abs(currentTime - previousTime) <= OPERATIONAL_TOUR_DUPLICATE_WINDOW_MS &&
buildSignature(previous) === buildSignature(report)
) {
dedupedHistory[dedupedHistory.length - 1] = report;
continue;
}
}
dedupedHistory.push(report);
}
let reports = dedupedHistory.slice(0, OPERATIONAL_TOUR_HISTORY_MAX_REPORTS).map((report) => ({
...report,
steps: report.steps.map(compactStep),
}));
while (reports.length > 0) {
const serialized = JSON.stringify(reports);
if (Buffer.byteLength(serialized, "utf8") <= OPERATIONAL_TOUR_HISTORY_MAX_BYTES) {
return reports;
}
reports = reports.slice(0, -1);
}
return [];
}
export async function saveOperationalTourHistory(history: OperationalTourReport[]) {
await db.setPortalSetting(
OPERATIONAL_TOUR_HISTORY_KEY,
JSON.stringify(compactOperationalTourHistory(history)),
"Historique du tour opérationnel du site"
);
}
export async function clearOperationalTourHistory() {
const history = await getOperationalTourHistory();
const seen = new Set<OperationalTourEnvironment>();
const retained = history.filter((report) => {
if (seen.has(report.environment)) {
return false;
}
seen.add(report.environment);
return true;
});
await saveOperationalTourHistory(retained);
return retained;
}
export async function runOperationalTour(params: {
environment: OperationalTourEnvironment;
req: Request;
currentUser: User;
}) {
const startedAt = new Date();
const settings = await getOperationalTourSettings();
const target = settings.targets[params.environment];
const baseUrl = normalizeBaseUrl(target.baseUrl);
const environmentLabel = params.environment === "production" ? "Production" : "Préproduction / Local";
const steps: OperationalTourStep[] = [];
if (!baseUrl) {
const report: OperationalTourReport = {
id: `tour-${Date.now()}`,
environment: params.environment,
environmentLabel,
baseUrl: "",
status: "error",
startedAt: startedAt.toISOString(),
finishedAt: new Date().toISOString(),
durationMs: 0,
initiatedBy: {
userId: params.currentUser.id,
name: params.currentUser.name || params.currentUser.email || `Utilisateur #${params.currentUser.id}`,
email: params.currentUser.email || undefined,
},
summary: { total: 1, ok: 0, warning: 0, error: 1 },
steps: [
{
id: "configuration",
label: "Configuration de lenvironnement",
kind: "technical",
status: "error",
durationMs: 0,
message: "URL cible manquante",
details: `Aucune URL nest configurée pour ${environmentLabel.toLowerCase()}.`,
critical: true,
},
],
};
const nextHistory = [report, ...(await getOperationalTourHistory())];
await saveOperationalTourHistory(nextHistory);
return report;
}
const currentOrigin = buildRequestOrigin(params.req);
const isSameOrigin = currentOrigin && normalizeBaseUrl(currentOrigin) === baseUrl;
const incomingCookieHeader = typeof params.req.headers.cookie === "string" ? params.req.headers.cookie : "";
const homeDiagnostics = await fetchHomeDiagnostics(baseUrl);
if (homeDiagnostics.ok) {
steps.push(
buildStaticStep({
id: "technical-performance-home",
label: "Performance de laccueil",
kind: "technical",
status: homeDiagnostics.durationMs <= 1500 ? "ok" : homeDiagnostics.durationMs <= 3000 ? "warning" : "error",
message:
homeDiagnostics.durationMs <= 1500
? "Temps de réponse confortable"
: homeDiagnostics.durationMs <= 3000
? "Temps de réponse correct mais à surveiller"
: "Temps de réponse trop élevé",
details: `${homeDiagnostics.durationMs} ms`,
critical: false,
durationMs: homeDiagnostics.durationMs,
}),
buildStaticStep({
id: "technical-security-baseline",
label: "Sécurité de base",
kind: "technical",
status: baseUrl.startsWith("https://") ? "ok" : "warning",
message: baseUrl.startsWith("https://") ? "Le site est servi en HTTPS" : "Le site nutilise pas HTTPS",
details: homeDiagnostics.finalUrl,
critical: false,
}),
buildStaticStep({
id: "technical-accessibility-baseline",
label: "Accessibilité de base",
kind: "technical",
status:
homeDiagnostics.body.includes("<title>")
&& /<html[^>]+lang=/i.test(homeDiagnostics.body)
? "ok"
: "warning",
message:
homeDiagnostics.body.includes("<title>")
&& /<html[^>]+lang=/i.test(homeDiagnostics.body)
? "Titre de page et langue du document détectés"
: "Titre ou langue du document à confirmer",
details: homeDiagnostics.finalUrl,
critical: false,
}),
buildStaticStep({
id: "technical-mobile-baseline",
label: "Compatibilité mobile",
kind: "technical",
status: /<meta[^>]+name=["']viewport["']/i.test(homeDiagnostics.body) ? "ok" : "warning",
message: /<meta[^>]+name=["']viewport["']/i.test(homeDiagnostics.body)
? "Viewport mobile détecté"
: "Meta viewport absente ou non détectée",
details: homeDiagnostics.finalUrl,
critical: false,
})
);
} else {
steps.push(
buildStaticStep({
id: "technical-performance-home",
label: "Performance de laccueil",
kind: "technical",
status: "error",
message: homeDiagnostics.error || "Impossible de mesurer laccueil",
details: homeDiagnostics.finalUrl,
critical: false,
durationMs: homeDiagnostics.durationMs,
})
);
}
steps.push(
await fetchJsonCheck({
id: "technical-api-public",
label: "API publique",
kind: "technical",
url: `${baseUrl}/api/trpc/auth.providers?input=%7B%7D`,
markers: ['"google"', '"facebook"'],
})
);
let adminCookieHeader: string | null = isSameOrigin && incomingCookieHeader ? incomingCookieHeader : null;
const hasConfiguredAdminAccount = Boolean(
target.adminEmail &&
(target.adminAuthMode === "oauth" || target.adminPassword)
);
if (!adminCookieHeader && hasConfiguredAdminAccount) {
try {
adminCookieHeader = await createConfiguredSessionCookie(target.adminEmail, target.adminAuthMode === "oauth" ? "" : target.adminPassword);
} catch (error) {
const usesOAuthOnly = target.adminAuthMode === "oauth";
steps.push({
id: "admin-config",
label: "Configuration compte admin de contrôle",
kind: "user",
status: "warning",
durationMs: 0,
message: usesOAuthOnly ? "Impossible douvrir une session admin OAuth" : "Impossible douvrir une session admin dédiée",
details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth admin introuvable" : "Identifiants admin invalides",
critical: false,
});
}
}
let associationCookieHeader: string | null = null;
const hasConfiguredAssociationAccount = Boolean(
target.associationEmail &&
(target.associationAuthMode === "oauth" || target.associationPassword)
);
if (hasConfiguredAssociationAccount) {
try {
associationCookieHeader = await createConfiguredSessionCookie(
target.associationEmail,
target.associationAuthMode === "oauth" ? "" : target.associationPassword
);
} catch (error) {
const usesOAuthOnly = target.associationAuthMode === "oauth";
steps.push({
id: "association-config",
label: "Configuration compte association de test",
kind: "user",
status: "warning",
durationMs: 0,
message: usesOAuthOnly ? "Impossible douvrir une session association OAuth" : "Impossible douvrir une session association",
details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth association introuvable" : "Identifiants association invalides",
critical: false,
});
}
} else {
steps.push({
id: "association-config",
label: "Configuration compte association de test",
kind: "user",
status: "warning",
durationMs: 0,
message: "Compte association non configuré",
details: "Les contrôles authentifiés association sont ignorés tant que lemail et le mot de passe ne sont pas renseignés.",
critical: false,
});
}
let accueilCookieHeader: string | null = null;
const hasConfiguredAccueilAccount = Boolean(
target.accueilEmail &&
(target.accueilAuthMode === "oauth" || target.accueilPassword)
);
if (hasConfiguredAccueilAccount) {
try {
accueilCookieHeader = await createConfiguredSessionCookie(
target.accueilEmail,
target.accueilAuthMode === "oauth" ? "" : target.accueilPassword
);
} catch (error) {
const usesOAuthOnly = target.accueilAuthMode === "oauth";
steps.push(buildStaticStep({
id: "accueil-config",
label: "Configuration compte accueil / hôtesse",
kind: "user",
status: "warning",
message: usesOAuthOnly ? "Impossible douvrir une session accueil OAuth" : "Impossible douvrir une session accueil",
details: error instanceof Error ? error.message : usesOAuthOnly ? "Compte OAuth accueil introuvable" : "Identifiants accueil invalides",
critical: false,
}));
}
} else {
steps.push(buildStaticStep({
id: "accueil-config",
label: "Configuration compte accueil / hôtesse",
kind: "user",
status: "warning",
message: "Compte accueil non configuré",
details: "Le parcours hôtesse reste ignoré tant quun compte accueil nest pas renseigné.",
critical: false,
}));
}
steps.push(
await fetchPageCheck({ id: "technical-public-home", label: "Navigation publique • Accueil", kind: "technical", url: `${baseUrl}/`, expectedPath: "/", markers: ["Portail Associations", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "technical-public-login", label: "Navigation publique • Connexion", kind: "technical", url: `${baseUrl}/login`, expectedPath: "/login", markers: ["Connexion", "Se connecter"] }),
await fetchPageCheck({ id: "technical-directory", label: "Annuaire public des associations", kind: "technical", url: `${baseUrl}/associations`, expectedPath: "/associations", markers: ["Annuaire des associations", "associationDirectory.listPortal"] }),
await fetchPageCheck({ id: "technical-map", label: "Cartographie des associations", kind: "technical", url: `${baseUrl}/associations/carte`, expectedPath: "/associations/carte", markers: ["Cartographie interactive", "associationDirectory.listMap"] }),
);
const publicProfileUrl = await tryFindPublicProfileUrl(baseUrl);
if (publicProfileUrl) {
steps.push(
await fetchPageCheck({
id: "technical-public-profile",
label: "Fiche publique association",
kind: "technical",
url: publicProfileUrl,
expectedPath: new URL(publicProfileUrl).pathname,
markers: ["Coordonnées", "actualités et publications"],
critical: false,
})
);
} else {
steps.push(buildStaticStep({
id: "technical-public-profile",
label: "Fiche publique association",
kind: "technical",
status: "warning",
durationMs: 0,
message: "Aucune fiche publique détectée",
details: "Le tour na pas trouvé de lien de fiche association dans lannuaire public.",
critical: false,
}));
}
if (associationCookieHeader) {
steps.push(
await fetchJsonCheck({
id: "technical-api-association",
label: "API association",
kind: "technical",
url: `${baseUrl}/api/trpc/auth.me?input=%7B%7D`,
cookieHeader: associationCookieHeader,
markers: ['"email"', '"role"'],
}),
await fetchPageCheck({ id: "user-association-dashboard", label: "Parcours association • Tableau de bord", kind: "user", url: `${baseUrl}/dashboard`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard", markers: ["Tableau de bord", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "user-association-requests", label: "Parcours association • Mes demandes", kind: "user", url: `${baseUrl}/dashboard/requests`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/requests", markers: ["Mes demandes"] }),
await fetchPageCheck({ id: "user-association-documents", label: "Parcours association • Mes documents", kind: "user", url: `${baseUrl}/dashboard/documents`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/documents", markers: ["Mes documents"] }),
await fetchPageCheck({ id: "user-association-directory", label: "Parcours association • Annuaire", kind: "user", url: `${baseUrl}/dashboard/associations`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/associations", markers: ["Annuaire des associations"], critical: false }),
await fetchPageCheck({ id: "user-association-form-salle", label: "Formulaire • Réservation de salle", kind: "user", url: `${baseUrl}/dashboard/reservation-salle`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/reservation-salle", markers: ["Réservation de salle", "Soumettre"] }),
await fetchPageCheck({ id: "user-association-form-request", label: "Formulaire • Nouvelle demande", kind: "user", url: `${baseUrl}/dashboard/requests/new`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/requests/new", markers: ["Nouvelle demande", "Soumettre"] }),
await fetchPageCheck({ id: "user-association-form-material", label: "Formulaire • Matériel événementiel", kind: "user", url: `${baseUrl}/dashboard/materiel-evenementiel`, cookieHeader: associationCookieHeader, expectedPath: "/dashboard/materiel-evenementiel", markers: ["matériel événementiel", "Soumettre"], critical: false }),
);
}
if (accueilCookieHeader) {
steps.push(
await fetchJsonCheck({
id: "technical-api-accueil",
label: "API accueil / hôtesse",
kind: "technical",
url: `${baseUrl}/api/trpc/auth.me?input=%7B%7D`,
cookieHeader: accueilCookieHeader,
markers: ['"accueil"'],
critical: false,
}),
await fetchPageCheck({ id: "user-accueil-dashboard", label: "Parcours hôtesse • Administration", kind: "user", url: `${baseUrl}/admin`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Administration accueil", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "user-accueil-requests", label: "Parcours hôtesse • Gestion des demandes", kind: "user", url: `${baseUrl}/admin?tab=requests`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Gestion des demandes"] }),
await fetchPageCheck({ id: "user-accueil-calendar", label: "Parcours hôtesse • Calendrier", kind: "user", url: `${baseUrl}/admin?tab=calendrier`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Calendrier"] }),
await fetchPageCheck({ id: "user-accueil-notifications", label: "Parcours hôtesse • Notifications", kind: "user", url: `${baseUrl}/admin?tab=notifications`, cookieHeader: accueilCookieHeader, expectedPath: "/admin", markers: ["Notifications"], critical: false }),
);
}
if (adminCookieHeader) {
steps.push(
await fetchJsonCheck({
id: "technical-api-admin",
label: "API administration",
kind: "technical",
url: `${baseUrl}/api/internal/stats/reservations?period=month`,
cookieHeader: adminCookieHeader,
markers: ['"summary"', '"history"'],
}),
await fetchPageCheck({ id: "user-admin-dashboard", label: "Parcours administrateur • Administration", kind: "user", url: `${baseUrl}/admin`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Tableau de bord", "<title>Portail Associations</title>"] }),
await fetchPageCheck({ id: "user-admin-requests", label: "Parcours administrateur • Gestion des demandes", kind: "user", url: `${baseUrl}/admin?tab=requests`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Gestion des demandes"] }),
await fetchPageCheck({ id: "user-admin-calendar", label: "Parcours administrateur • Calendrier", kind: "user", url: `${baseUrl}/admin?tab=calendrier`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Calendrier"] }),
await fetchPageCheck({ id: "user-admin-analytics", label: "Parcours administrateur • Statistiques", kind: "user", url: `${baseUrl}/admin?tab=analytics`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Statistiques dactivité", "Tour opérationnel du site"] }),
await fetchPageCheck({ id: "user-admin-ccds-directory", label: "Parcours administrateur • Annuaire interne CCDS", kind: "user", url: `${baseUrl}/admin?tab=ccds-directory`, cookieHeader: adminCookieHeader, expectedPath: "/admin", markers: ["Annuaire interne CCDS"] }),
);
} else {
steps.push(buildStaticStep({
id: "admin-config",
label: "Configuration session admin",
kind: "user",
status: "warning",
durationMs: 0,
message: "Aucune session admin disponible pour cet environnement",
details: isSameOrigin
? "Le navigateur na pas transmis de cookie de session exploitable."
: "Renseignez un compte admin dédié si vous voulez tester un autre environnement que celui où vous êtes connecté.",
critical: false,
}));
}
const finishedAt = new Date();
const report: OperationalTourReport = {
id: `tour-${finishedAt.getTime()}`,
environment: params.environment,
environmentLabel,
baseUrl,
status: computeReportStatus(steps),
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
initiatedBy: {
userId: params.currentUser.id,
name: params.currentUser.name || params.currentUser.email || `Utilisateur #${params.currentUser.id}`,
email: params.currentUser.email || undefined,
},
summary: summarizeSteps(steps),
steps,
};
const nextHistory = [report, ...(await getOperationalTourHistory())];
await saveOperationalTourHistory(nextHistory);
return report;
}