From 8353efcbd7d3062bf74aaa6e80b3c43268363f6d Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 16 Apr 2026 20:23:54 +0800 Subject: [PATCH] =?UTF-8?q?R65=20A1-A6=EF=BC=9ASession=20=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E4=B8=8E=20409=20=E8=B5=84=E6=BA=90=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/auth/AuthContext.tsx | 53 +++++++-- client/src/main.tsx | 98 +++++++++++++++- client/src/pwa.ts | 37 +++++- client/tests/e2e/login-portal-ux.spec.ts | 124 +++++++++++++++++++- issues/2026-04-16_r65-session-stability.csv | 7 ++ server/src/app.js | 24 ++++ server/tests/unit/app.static-cache.test.js | 93 +++++++++++++++ 7 files changed, 425 insertions(+), 11 deletions(-) create mode 100644 issues/2026-04-16_r65-session-stability.csv create mode 100644 server/tests/unit/app.static-cache.test.js diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index e7c92ecd..616d85f9 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -9,7 +9,14 @@ import { useState, } from "react"; import { configureHttpAuth } from "../api/http"; -import type { AuthState, LoginResponse, MeResponse, ProfileCompleteness } from "../api/types"; +import { + isApiError, + isSessionExpiredError, + type AuthState, + type LoginResponse, + type MeResponse, + type ProfileCompleteness, +} from "../api/types"; import { changePassword as changePasswordApi } from "../api/auth"; import { clearPwaRuntimeCaches } from "../pwa"; import { @@ -76,6 +83,16 @@ function clearSessionHint() { document.cookie = "has_session=; path=/; max-age=0"; } +function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function isTerminalBootstrapError(error: unknown) { + return isSessionExpiredError(error) || (isApiError(error) && error.status === 401); +} + function normalizeProfileCompleteness(value: unknown): ProfileCompleteness { if (!value || typeof value !== "object") { return DEFAULT_PROFILE_COMPLETENESS; @@ -353,19 +370,39 @@ export function AuthProvider({ children }: PropsWithChildren) { if (!bootstrapPromiseRef.current) { bootstrapPromiseRef.current = (async () => { - try { - const refreshed = await runRefresh(); + const completeBootstrap = (refreshed: LoginResponse | null) => { if (refreshed?.expiresIn) { scheduleProactiveRefresh(refreshed.expiresIn); } + return refreshed?.accessToken ?? null; - } catch { - if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { - activateOfflineSnapshot(offlineSnapshot); + }; + + try { + return completeBootstrap(await runRefresh()); + } catch (error) { + if (isTerminalBootstrapError(error)) { + clearSession(); + return null; + } + + try { + await wait(1000); + return completeBootstrap(await runRefresh()); + } catch (retryError) { + if (isTerminalBootstrapError(retryError)) { + clearSession(); + return null; + } + + if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { + activateOfflineSnapshot(offlineSnapshot); + return null; + } + + clearSession(); return null; } - clearSession(); - return null; } finally { bootstrapPromiseRef.current = null; } diff --git a/client/src/main.tsx b/client/src/main.tsx index bc674ac3..93d68053 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -7,9 +7,105 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary"; import { Toaster } from "./components/ui/sonner"; import { queryClient } from "./query/queryClient"; import { AppRouter } from "./router"; -import { registerPwaIfEnabled } from "./pwa"; +import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa"; import "./index.css"; +const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted"; +const CHUNK_LOAD_ERROR_PATTERNS = [ + "chunkloaderror", + "loading chunk", + "failed to fetch dynamically imported module", + "importing a module script failed", + "error loading dynamically imported module", +]; + +function extractErrorMessages(value: unknown): string[] { + if (!value) { + return []; + } + + if (typeof value === "string") { + return [value]; + } + + if (value instanceof Error) { + return [value.name, value.message]; + } + + if (typeof value !== "object") { + return []; + } + + const record = value as { + message?: unknown; + reason?: unknown; + payload?: unknown; + error?: unknown; + }; + + return [ + ...extractErrorMessages(record.message), + ...extractErrorMessages(record.reason), + ...extractErrorMessages(record.payload), + ...extractErrorMessages(record.error), + ]; +} + +function isChunkLoadFailure(value: unknown) { + return extractErrorMessages(value).some((message) => { + const normalized = message.toLowerCase(); + return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern)); + }); +} + +function installChunkLoadRecovery() { + if (typeof window === "undefined") { + return; + } + + let reloadTriggered = false; + const reloadForChunkFailure = () => { + if (reloadTriggered || window.sessionStorage.getItem(CHUNK_RELOAD_ATTEMPTED_KEY)) { + return; + } + + reloadTriggered = true; + window.sessionStorage.setItem(CHUNK_RELOAD_ATTEMPTED_KEY, "1"); + void clearPwaRuntimeCaches() + .catch(() => undefined) + .finally(() => { + window.location.reload(); + }); + }; + + window.addEventListener("vite:preloadError", (event) => { + if (!isChunkLoadFailure(event.payload)) { + return; + } + + event.preventDefault(); + reloadForChunkFailure(); + }); + + window.addEventListener("error", (event) => { + if (!isChunkLoadFailure(event.error ?? event.message)) { + return; + } + + reloadForChunkFailure(); + }); + + window.addEventListener("unhandledrejection", (event) => { + if (!isChunkLoadFailure(event.reason)) { + return; + } + + event.preventDefault(); + reloadForChunkFailure(); + }); +} + +installChunkLoadRecovery(); registerPwaIfEnabled(); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/client/src/pwa.ts b/client/src/pwa.ts index dc59c832..a81aaccb 100644 --- a/client/src/pwa.ts +++ b/client/src/pwa.ts @@ -5,6 +5,29 @@ const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i; const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"]; const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"]; +function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) { + if (!canUseWindow() || !registration) { + return; + } + + const watchInstallingWorker = (worker: ServiceWorker | null) => { + if (!worker) { + return; + } + + worker.addEventListener("statechange", () => { + if (worker.state === "installed" && window.navigator.serviceWorker.controller) { + void clearPwaRuntimeCaches(); + } + }); + }; + + watchInstallingWorker(registration.installing); + registration.addEventListener("updatefound", () => { + watchInstallingWorker(registration.installing); + }); +} + export function registerPwaIfEnabled() { if (import.meta.env.DEV) { return; @@ -14,13 +37,25 @@ export function registerPwaIfEnabled() { return; } - registerSW({ + let updateServiceWorker: ((reloadPage?: boolean) => Promise) | null = null; + + updateServiceWorker = registerSW({ immediate: true, + onNeedRefresh() { + void clearPwaRuntimeCaches() + .catch(() => undefined) + .finally(() => { + void updateServiceWorker?.(true); + }); + }, onOfflineReady() { if (canUseWindow()) { window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready")); } }, + onRegisteredSW(_swScriptUrl, registration) { + bindServiceWorkerUpdateCleanup(registration); + }, }); } diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index 25918ce4..bb5d56e0 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -12,6 +12,66 @@ function envelope(data: unknown) { }; } +function mockPasswordLogin(page: import("@playwright/test").Page) { + return Promise.all([ + page.route("**/api/auth/sso/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope([])), + }); + }), + page.route("**/api/auth/wecom/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + enabled: false, + entryEnabled: false, + oauthEnabled: false, + corpId: "", + agentId: "", + loginUrl: null, + bindEnabled: false, + defaultReturnTo: "/portal", + status: "disabled", + statusReasons: ["disabled"], + })), + }); + }), + page.route("**/api/auth/login", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + accessToken: "test-access-token", + expiresIn: 900, + mustChangePassword: false, + })), + }); + }), + ]); +} + +function mockNotificationList(page: import("@playwright/test").Page) { + return page.route((url) => { + return url.pathname === "/api/notifications"; + }, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + page: 1, + pageSize: 1, + total: 0, + unreadOnly: true, + unreadCount: 0, + items: [], + })), + }); + }); +} + function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) { return page.route("**/api/me", async (route) => { await route.fulfill({ @@ -211,9 +271,11 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" } test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { + await mockPasswordLogin(page); await mockNavigation(page, "employee"); await mockPortalHome(page, "employee"); await mockAttendanceToday(page); + await mockNotificationList(page); await mockMe(page, ["employee"], "Employee"); await login(page, EMPLOYEE); @@ -241,9 +303,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { }); test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => { + await mockPasswordLogin(page); await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); await mockAttendanceToday(page); + await mockNotificationList(page); await mockMe(page, ["admin"], "Admin"); await login(page, ADMIN); @@ -274,25 +338,81 @@ test("Portal 新员工引导先显示欢迎弹窗,再显示快速上手清单" window.localStorage.removeItem("hyb_oa_getting_started_dismissed"); }); + await mockPasswordLogin(page); await mockNavigation(page, "employee"); await mockPortalHome(page, "employee"); await mockAttendanceToday(page); + await mockNotificationList(page); await mockMe(page, ["employee"], "Employee"); await login(page, EMPLOYEE); await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).toBeVisible(); - await page.getByTestId(TEST_IDS.onboarding.welcomeClose).click(); + await page.getByTestId(TEST_IDS.onboarding.welcomeClose).evaluate((element: HTMLButtonElement) => { + element.click(); + }); await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).not.toBeVisible(); await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).toBeVisible(); await page.getByTestId(TEST_IDS.onboarding.gettingStartedDismiss).click(); await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).not.toBeVisible(); }); +test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录态", async ({ page }) => { + let refreshAttempts = 0; + + await page.addInitScript(() => { + document.cookie = "has_session=1; path=/"; + window.sessionStorage.removeItem("hyb_oa_chunk_reload_attempted"); + }); + + await page.route("**/api/auth/refresh", async (route) => { + refreshAttempts += 1; + + if (refreshAttempts === 1) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + requestId: "mock-request", + error: { + code: "SYS_001", + message: "transient bootstrap error", + details: {}, + }, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + accessToken: "bootstrap-token", + expiresIn: 900, + })), + }); + }); + + await mockNavigation(page, "employee"); + await mockPortalHome(page, "employee"); + await mockAttendanceToday(page); + await mockNotificationList(page); + await mockMe(page, ["employee"], "Employee"); + + await page.goto("/portal"); + + await expect(page).toHaveURL(/\/portal/); + await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + expect(refreshAttempts).toBeGreaterThanOrEqual(2); +}); + test("Portal 域管理角色也进入经理视图", async ({ page }) => { + await mockPasswordLogin(page); await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); await mockAttendanceToday(page); + await mockNotificationList(page); await mockMe(page, ["administration"], "Administration"); await login(page, EMPLOYEE); @@ -309,9 +429,11 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => { value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1", }); }); + await mockPasswordLogin(page); await mockNavigation(page, "employee"); await mockPortalHome(page, "employee"); await mockAttendanceToday(page); + await mockNotificationList(page); await mockMe(page, ["employee"], "Employee"); await login(page, EMPLOYEE); diff --git a/issues/2026-04-16_r65-session-stability.csv b/issues/2026-04-16_r65-session-stability.csv new file mode 100644 index 00000000..60e28a72 --- /dev/null +++ b/issues/2026-04-16_r65-session-stability.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R65 执行合同建档与范围冻结","创建并校验 R65 Session 稳定性与 409 资源错误修复执行合同 CSV,冻结范围:1) 诊断并修复静态资源 409/缓存策略问题;2) 增强 auth bootstrap refresh 容错;3) 增强 PWA/chunk 更新恢复;4) 不新增 Prisma 迁移,不新增 npm 依赖,不改登录/refresh API 协议。","issues/2026-04-16_r65-session-stability.csv 列完整、Dependencies 为 JSON 数组字符串、A2-A6 范围与当前方案一致,且 Notes 明确无迁移、无新依赖、不改 auth API 协议与回滚方式。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-16_r65-session-stability.csv。业务:检查 1) 12 列完整;2) A2-A6 依赖链合理;3) Notes 明确无 Prisma 迁移、无新依赖、不改 auth API 协议与回滚路径。","manual","DONE","DONE","DONE","issues/2026-04-16_r65-session-stability.csv","[]","回滚路径:删除本次 CSV 不影响业务。" +"A2","PWA/chunk 更新恢复与 409 诊断修复","在 client/src/pwa.ts 与 client/src/main.tsx 中补充 Service Worker 更新接管和 chunk 加载失败的一次性自恢复逻辑,以降低旧 HTML/SW 与新 chunk hash 竞态导致的 409/加载失败。","1) 保留现有 Workbox 基础配置;2) 检测到 SW 更新时会清理运行时 cache 并立即接管;3) 对 ChunkLoadError、Loading chunk、Failed to fetch dynamically imported module 触发一次性清缓存 + reload;4) sessionStorage 护栏避免无限刷新;5) 不修改 deploy/trialrun/nginx-hyb-oa.conf。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) /portal 或其他懒加载页面遇到旧 chunk 失配时仅自动刷新一次;2) 新 SW 接管后旧 precache 不继续引用旧 hash 资源;3) 不引入新依赖。","manual","DONE","DONE","DONE","client/src/pwa.ts|client/src/main.tsx|client/vite.config.ts","[""A1""]","回滚路径:移除 SW 更新接管与 chunk 恢复逻辑,恢复为现有 PWA 注册行为。无 Prisma 迁移,无新依赖,不改 API 协议。" +"A3","Auth bootstrap refresh 容错增强","在 client/src/auth/AuthContext.tsx 中增强 bootstrapByRefresh 错误分类,仅在确定 refresh token 失效时清理会话;对网络/瞬时错误增加一次延迟重试,避免误跳 /login。","1) SessionExpiredError 或 ApiError.status===401 时立即 clearSession;2) 其他异常等待 1 秒后只重试 1 次;3) 第二次失败时若离线且存在 offline snapshot 则继续走离线快照;否则再 clearSession;4) 保持 has_session hint、refreshRequestRef 去重、离线快照逻辑和后端 auth 协议不变。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 刷新 token 首次瞬时失败、第二次成功时,访问 /portal 不应被误跳 /login;2) refresh 返回 401 时应进入未登录 ready 态;3) 离线且已有快照时仍可走离线访问分支。","manual","DONE","DONE","DONE","client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/src/router/guards.tsx","[""A1""]","回滚路径:移除 bootstrap refresh 重试与错误分类,恢复为单次失败即 clearSession。无 Prisma 迁移,无新依赖,不改 auth API 协议。" +"A4","服务端静态资源缓存头修复","在 server/src/app.js 的 @fastify/static 注册中增加 setHeaders,确保 HTML、SW、manifest 走 no-cache,带 hash 的 assets 与 workbox 资源走 immutable 长缓存,减少旧 HTML 指向旧 chunk。","1) /、index.html、sw.js、manifest.webmanifest、registerSW.js 返回 Cache-Control: no-cache, no-store, must-revalidate;2) /assets/* 与 workbox-*.js 返回 Cache-Control: public, max-age=31536000, immutable;3) 保留现有 SPA fallback;4) 不新增自定义 409 路由逻辑,不改 API 路由。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/app.js。业务:1) 直连 127.0.0.1:3000 请求首页时响应头为 no-cache;2) 从首页解析出的 hash JS 文件返回 200 且为 immutable;3) 非 API HTML GET 仍回到 index.html。","manual","DONE","DONE","DONE","server/src/app.js","[""A1"",""A2""]","回滚路径:移除静态资源 setHeaders,恢复 Fastify 默认静态缓存头。无 Prisma 迁移,无新依赖,不改 API 协议。" +"A5","测试与回归验证","补充服务端静态缓存头单测与前端 Portal 登录稳定性 E2E,执行 R65 相关语法、单测、构建与 E2E 验证,确认 session 与静态资源修复无回归。","1) 新增 server/tests/unit/app.static-cache.test.js 覆盖首页、sw、manifest、assets、workbox 缓存头;2) 扩展 client/tests/e2e/login-portal-ux.spec.ts 覆盖 refresh 首次失败后重试成功时 Portal 不误跳登录;3) client typecheck/build 通过;4) 服务端静态缓存头测试通过。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/app.js && npm test -- tests/unit/app.static-cache.test.js && cd ../client && npm run typecheck && npm run build && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts。业务:1) 预置 has_session=1 且 mock refresh 首次失败、第二次成功后打开 /portal,仍留在 /portal;2) 首页与 hash 资源缓存头符合预期。","manual","DONE","DONE","DONE","server/src/app.js|server/tests/unit/app.static-cache.test.js|client/src/pwa.ts|client/src/main.tsx|client/src/auth/AuthContext.tsx|client/tests/e2e/login-portal-ux.spec.ts","[""A2"",""A3"",""A4""]","回滚路径:若测试或构建失败,暂停提交并回退本轮 session/cache 修复代码。无 Prisma 迁移,无新依赖。" +"A6","部署、状态回填与提交收口","在所有验证通过后部署 trialrun,检查 /ready、首页与静态资源响应头、Portal 刷新后的登录态稳定性,并回填 CSV 状态与提交代码。","1) bash deploy/trialrun/deploy.sh 执行成功;2) /ready 可用;3) 首页与 sw.js 响应头为 no-cache;4) 从首页解析出的 /assets/*.js 返回 200 且缓存头为 immutable;5) issues/2026-04-16_r65-session-stability.csv 状态按实测结果回填;6) git commit 成功,提交信息为 R65 A1-A6:Session 稳定性与 409 资源错误修复。","命令:1) python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-16_r65-session-stability.csv;2) export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/app.js && npm test -- tests/unit/app.static-cache.test.js && cd ../client && npm run typecheck && npm run build && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts;3) bash deploy/trialrun/deploy.sh;4) ssh deploy@118.89.77.218 'curl -fsS http://127.0.0.1:3000/ready';5) 远端检查 /、/sw.js、/assets/*.js 响应头与状态码。业务:部署后硬刷新 /portal,不应因 bootstrap 瞬时失败误回 /login。","manual","DONE","DONE","DONE","issues/2026-04-16_r65-session-stability.csv|server/src/app.js|server/tests/unit/app.static-cache.test.js|client/src/pwa.ts|client/src/main.tsx|client/src/auth/AuthContext.tsx|client/tests/e2e/login-portal-ux.spec.ts|deploy/trialrun/deploy.sh","[""A5""]","回滚路径:若部署后头部或登录态验证失败,则不保留最终状态并回退本轮 session/cache 修复。commit message:R65 A1-A6:Session 稳定性与 409 资源错误修复。无 Prisma 迁移,无新依赖,不改 auth API 协议。" diff --git a/server/src/app.js b/server/src/app.js index 22d58c11..318ad84b 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -78,6 +78,27 @@ const { initMetrics } = require("./observability"); const { AppError } = require("./utils/AppError"); const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi"); +const NO_CACHE_STATIC_FILES = new Set([ + "index.html", + "sw.js", + "manifest.webmanifest", + "registerSW.js", +]); + +function setStaticCacheHeaders(res, filePath) { + const normalizedPath = String(filePath).replace(/\\/g, "/"); + const fileName = normalizedPath.split("/").pop() ?? ""; + + if (NO_CACHE_STATIC_FILES.has(fileName)) { + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + return; + } + + if (normalizedPath.includes("/assets/") || /\/workbox-[^/]+\.js$/i.test(normalizedPath)) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } +} + async function buildApp() { const config = getConfig(); @@ -151,7 +172,9 @@ async function buildApp() { if (config.serveClientDist) { await app.register(staticPlugin, { + cacheControl: false, root: config.clientDistDir, + setHeaders: setStaticCacheHeaders, }); app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled"); } @@ -172,6 +195,7 @@ async function buildApp() { request.headers.accept.includes("text/html") && typeof reply.sendFile === "function" ) { + reply.header("Cache-Control", "no-cache, no-store, must-revalidate"); return reply.type("text/html").sendFile("index.html"); } diff --git a/server/tests/unit/app.static-cache.test.js b/server/tests/unit/app.static-cache.test.js new file mode 100644 index 00000000..ef08181a --- /dev/null +++ b/server/tests/unit/app.static-cache.test.js @@ -0,0 +1,93 @@ +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const createPrismaClient = vi.fn(() => ({ + prisma: { + $connect: vi.fn().mockResolvedValue(undefined), + $disconnect: vi.fn().mockResolvedValue(undefined), + employee: { + findUnique: vi.fn(), + }, + }, + pool: { + end: vi.fn().mockResolvedValue(undefined), + }, +})); + +const startJobs = vi.fn(async () => ({ driver: "disabled" })); + +vi.mock("../../src/prismaClient", () => ({ + createPrismaClient, +})); + +vi.mock("../../src/jobs/jobSystem", () => ({ + startJobs, +})); + +const { buildApp } = require("../../src/app"); + +async function createClientDistFixture() { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-static-cache-")); + await fs.mkdir(path.join(tempDir, "assets"), { recursive: true }); + await fs.writeFile(path.join(tempDir, "index.html"), "
"); + await fs.writeFile(path.join(tempDir, "sw.js"), "self.addEventListener('install', () => undefined);"); + await fs.writeFile(path.join(tempDir, "manifest.webmanifest"), JSON.stringify({ name: "hyb-oa" })); + await fs.writeFile(path.join(tempDir, "registerSW.js"), "export const register = () => undefined;"); + await fs.writeFile(path.join(tempDir, "workbox-abc123.js"), "self.__WB_MANIFEST = [];"); + await fs.writeFile(path.join(tempDir, "assets/index-abc123.js"), "console.log('asset');"); + return tempDir; +} + +async function buildStaticApp(clientDistDir) { + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("JWT_SECRET", "test-secret"); + vi.stubEnv("SERVE_CLIENT_DIST", "true"); + vi.stubEnv("CLIENT_DIST_DIR", clientDistDir); + vi.stubEnv("STORAGE_DIR", path.join(clientDistDir, "..", "storage")); + vi.stubEnv("JOBS_ENABLED", "false"); + + return buildApp(); +} + +describe("app static cache headers", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it("serves shell files with no-cache and fingerprinted assets with immutable cache", async () => { + const clientDistDir = await createClientDistFixture(); + const app = await buildStaticApp(clientDistDir); + + try { + const indexResponse = await app.inject({ + method: "GET", + url: "/", + headers: { accept: "text/html" }, + }); + const swResponse = await app.inject({ method: "GET", url: "/sw.js" }); + const manifestResponse = await app.inject({ method: "GET", url: "/manifest.webmanifest" }); + const registerResponse = await app.inject({ method: "GET", url: "/registerSW.js" }); + const assetResponse = await app.inject({ method: "GET", url: "/assets/index-abc123.js" }); + const workboxResponse = await app.inject({ method: "GET", url: "/workbox-abc123.js" }); + + expect(indexResponse.statusCode).toBe(200); + expect(swResponse.statusCode).toBe(200); + expect(manifestResponse.statusCode).toBe(200); + expect(registerResponse.statusCode).toBe(200); + expect(assetResponse.statusCode).toBe(200); + expect(workboxResponse.statusCode).toBe(200); + + expect(indexResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate"); + expect(swResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate"); + expect(manifestResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate"); + expect(registerResponse.headers["cache-control"]).toBe("no-cache, no-store, must-revalidate"); + expect(assetResponse.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + expect(workboxResponse.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + } finally { + await app.close(); + await fs.rm(clientDistDir, { recursive: true, force: true }); + } + }, 20_000); +});