From 3cd606afec0433c4243d9d0c527b531002b8cc17 Mon Sep 17 00:00:00 2001 From: DemoJustLuGuo Date: Wed, 15 Jul 2026 20:51:49 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20dev=20?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=20node=5Fmodules=20=E8=A2=AB=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E6=8C=82=E8=BD=BD=E8=A6=86=E7=9B=96=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Dockerfile dev target 保留 devDependencies 供 pnpm dev 热更新, compose 为 oidc-op 加匿名卷隔离 /app/node_modules,避免宿主(Windows) 挂载覆盖容器内 Linux 原生依赖。 --- Dockerfile | 12 ++++++++++++ deploy/docker-compose.yml | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/Dockerfile b/Dockerfile index 5e804de..9f7bed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,15 @@ +# Dev target: full install (incl. devDependencies) for `pnpm dev` hot-reload. +# Source is bind-mounted at runtime; only node_modules is baked in so the +# compose anonymous volume can seed a Linux-native install over the Windows host. +FROM public.ecr.aws/docker/library/node:24-alpine AS dev +WORKDIR /app +ENV NODE_ENV=development +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod=false +EXPOSE 3003 +CMD ["pnpm", "dev"] + FROM public.ecr.aws/docker/library/node:24-alpine AS builder WORKDIR /app diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b8b9f11..dbd1fd3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -5,6 +5,7 @@ services: build: context: .. dockerfile: Dockerfile + target: dev user: root env_file: - ./.env @@ -23,6 +24,9 @@ services: - "${OIDC_APP_PORT:-3003}:3003" volumes: - ../:/app + # Anonymous volume keeps the container's Linux-native node_modules from + # being shadowed by the host bind mount (host is Windows, container is alpine). + - /app/node_modules - ./oidc-clients.json:${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json}:ro command: sh -c "corepack enable && pnpm dev" depends_on: From a3a935cc24b28726edeb91b934927bd2e9f0b20a Mon Sep 17 00:00:00 2001 From: DemoJustLuGuo Date: Thu, 16 Jul 2026 12:14:44 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E8=81=94?= =?UTF-8?q?=E8=B0=83=E5=8F=91=E7=8E=B0=E7=9A=84=E8=AE=A4=E8=AF=81=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- INTEGRATION_BUGS.md | 150 ++++++++++++++++++ src/routes/interactions.ts | 9 +- test/oidc-op.test.ts | 79 +++++---- .../app/providers/access-control-provider.ts | 3 +- web/src/app/providers/auth-provider.test.ts | 40 +++++ web/src/app/providers/auth-provider.ts | 5 + web/vite.config.ts | 2 +- 7 files changed, 256 insertions(+), 32 deletions(-) create mode 100644 INTEGRATION_BUGS.md create mode 100644 web/src/app/providers/auth-provider.test.ts diff --git a/INTEGRATION_BUGS.md b/INTEGRATION_BUGS.md new file mode 100644 index 0000000..56aa8e6 --- /dev/null +++ b/INTEGRATION_BUGS.md @@ -0,0 +1,150 @@ +# 联调缺陷与环境问题记录 + +> 最后更新:2026-07-16 +> +> 范围:本地 Docker 开发栈(`oidc-op`、PostgreSQL、Redis)及 React 管理后台。 +> 本文不记录会话 Cookie、CSRF token、账号、密码、客户端密钥或邮件通道密钥。 + +## 状态一览 + +| 编号 | 类型 | 状态 | 摘要 | +| ------- | ------------- | ---------- | ----------------------------------------------------------------- | +| INT-001 | 前端缺陷 | 已修复 | 管理台登录出现 `CSRF validation failed`。 | +| INT-002 | 开发栈缺陷 | 待修复 | 管理端“重启服务”会使 `pnpm dev` 容器中的 API 子进程停止且不恢复。 | +| INT-003 | 配置状态 | 已验证 | 邮件未配置时 readiness degraded,配置并重启后恢复 ready。 | +| INT-004 | 交接信息问题 | 已澄清 | 交接文本中的资源 ID 与数据库实际 ID 不一致。 | +| INT-005 | 本机环境问题 | 有替代方案 | 宿主 Windows 不能直接执行 `vitest`,容器内可正常执行。 | +| INT-006 | OIDC 流程缺陷 | 已修复 | 未请求 `email` scope 仍强制进入邮箱补充流程。 | +| INT-007 | 静态资源缺陷 | 已修复 | Vite 未将 favicon 复制到管理台构建目录。 | +| INT-008 | 测试环境问题 | 有替代方案 | 容器联调环境变量污染默认测试运行。 | + +## INT-001:管理台登录 CSRF 校验失败 + +- **现象**:登录页提交账号密码后显示 `ApiError: CSRF validation failed`。 +- **影响**:管理后台无法建立会话,阻断客户端创建、Revision 审核和系统设置操作。 +- **根因**: + 1. `access-control-provider` 请求 `/auth/context` 后未调用 `setCsrfToken`; + 2. `authProvider.login` 直接提交 `/auth/login`,没有保证当前内存中的 token 与匿名 CSRF nonce Cookie 对应。 + +服务端登录接口要求双提交校验:匿名请求先由 `/auth/context` 设置 +`cqut_manage_csrf` Cookie 并返回同一 nonce 绑定的 token,随后登录请求必须在 +`X-CSRF-Token` 请求头中携带该 token。 + +### 修复 + +- `web/src/app/providers/access-control-provider.ts`:读取认证上下文后同步 CSRF token。 +- `web/src/app/providers/auth-provider.ts`:登录提交前强制刷新认证上下文并同步 token。 +- `web/src/app/providers/auth-provider.test.ts`:新增回归测试,断言请求顺序为 + `/auth/context` → 设置 token → `/auth/login`。 + +### 验证证据 + +- 正确 Cookie + `X-CSRF-Token` 的空凭据请求返回 `401`,而非 CSRF `400`,证明服务端握手有效。 +- 容器内 `pnpm test:ui`:10/10 通过。 +- 容器内 `pnpm lint`:通过。 + +### 复测步骤 + +1. 对 `http://127.0.0.1:3003` 执行硬刷新(Ctrl+F5)。 +2. 使用管理台登录页提交有效学校账号。 +3. 预期不再出现 CSRF 错误;若浏览器仍缓存旧前端资源,删除该站点的 + `cqut_manage_csrf` Cookie 后刷新再试。 + +## INT-002:开发模式下应用内重启使 API 不可用 + +- **现象**:`POST /api/management/settings/runtime-policy/restart` 返回 `202` 后, + 3003 端口拒绝连接,`/health/live` 长时间无法恢复。 +- **影响**:运行策略写入后,在 Docker 开发栈中无法依赖管理端按钮使新策略加载; + 此后所有 API 联调中断。 +- **复现条件**:Compose 服务以 `pnpm dev` 启动,内部使用 + `tsx watch --env-file=deploy/.env src/main.ts`。 +- **已观察证据**:重启回调对服务子进程发送 `SIGTERM`;`tsx watch` 进程及 + `concurrently` 父进程仍在,但不再存在实际监听 3003 的应用进程。 + +### 当前规避措施 + +使用 Compose 管理的重启恢复服务: + +```powershell +docker compose -f deploy/docker-compose.yml restart oidc-op +``` + +### 建议修复方向 + +在开发模式中禁用该应用内重启入口并提示使用 Compose 重启,或调整进程拓扑, +确保退出服务进程会使容器主进程退出并由 Docker 的 `restart: unless-stopped` +重新创建服务。修复后需验证 `202` 返回后 `/health/live` 自动恢复。 + +## INT-003:邮件未配置导致 readiness 为 degraded(非缺陷) + +- **现象**:`/health/live` 返回 `200`,但 `/health/ready` 返回 `503`,响应中包含 + `email: "unconfigured"`。 +- **判断**:这是阶段 5 邮件通道尚未配置时的预期就绪检查结果,不是服务存活故障。 +- **影响**:Docker 健康检查当前使用 `/health/ready`,因此 `oidc-op` 容器会显示 + `unhealthy`,即使 API 仍可提供服务。 +- **阶段 5 结果**:SMTP 配置加密入库并通过测试邮件和验证码验证;Compose + 重启后运行策略 `version=4`、`loadedVersion=4`、`restartRequired=false`, + `/health/ready` 从 `503` 恢复为 `200`。 + +## INT-004:交接文本中的联调资源 ID 与数据库不一致 + +- **现象**:按交接文本中的 `proj_...`、`client_...` 查询时未找到资源,最初误判为 + 数据卷缺失。 +- **数据库证据**:资源实际存在,真实 ID 为: + - 项目:`project_17jJ6LJfu8gm6-Mx3hEyAnBX` + - Web:`client_8rr5uLOjZ9k9b1M43mM_Yrz7` + - SPA:`client_A6M8Kgg_-C99EdphCvOZUkll` +- **判断**:数据未丢失,问题来自阶段交接文本中的 ID 与数据库不一致。 + +## INT-005:宿主 Windows 的前端测试可执行文件不可用 + +- **现象**:宿主执行 `pnpm test:ui` 时提示 `vitest is not recognized`。 +- **影响**:不能直接使用宿主的 `node_modules` 执行前端测试。 +- **规避措施**:使用运行中的 Linux 开发容器: + +```powershell +docker compose -f deploy/docker-compose.yml exec -T oidc-op pnpm test:ui +docker compose -f deploy/docker-compose.yml exec -T oidc-op pnpm lint +``` + +- **后续动作**:如需恢复宿主验证能力,重新为 Windows 安装匹配平台的依赖;注意不要影响 + Compose 中用于隔离 Linux 原生依赖的 `/app/node_modules` 匿名卷。 + +## INT-006:未请求 email scope 仍强制补充邮箱 + +- **现象**:首次登录主体尚无已验证邮箱时,即使授权请求仅包含 + `openid profile student`,登录后仍跳转到邮箱补充和验证码流程。 +- **影响**:不需要邮箱声明的客户端也被邮件通道阻断,使阶段 4 OIDC 流程错误依赖阶段 5 + 邮件配置。 +- **根因**:`src/routes/interactions.ts` 只检查主体邮箱状态,没有检查当前授权请求是否包含 + `email` scope。 + +### 修复 + +- 仅当当前请求包含 `email` scope,且主体缺少邮箱或需要验证时,才进入 `/profile`。 +- 新增回归测试:首次主体请求 `openid profile` 时,登录后直接继续授权,且不发送验证码。 + +## INT-007:管理台 favicon 未进入构建目录 + +- **现象**:访问 `/favicon.svg` 时服务日志出现 + `ENOENT: /app/dist/management/favicon.svg`。 +- **根因**:`web/vite.config.ts` 将 `publicDir` 指向 `web/src/assets`,但 favicon 位于 + `web/public/favicon.svg`。 +- **修复**:将 Vite `publicDir` 改为 `web/public`,构建后应生成 + `dist/management/favicon.svg`。 + +## INT-008:容器联调环境变量污染测试运行 + +- **现象**:直接在 `oidc-op` 容器执行 `pnpm test` 时,测试继承真实 + `DATABASE_URL`、`REDIS_URL` 和 `OIDC_RATE_LIMIT_FAIL_CLOSED=true`: + - 测试密钥无法解密联调库中的运行策略,产生级联失败; + - 仅清除数据库和 Redis 地址后,fail-closed 又使大量请求返回 `503`。 +- **判断**:这是测试启动环境隔离问题,不是测试用例回归。 +- **可靠命令**: + +```powershell +docker compose -f deploy/docker-compose.yml exec -T oidc-op sh -lc ` + 'unset DATABASE_URL REDIS_URL; export OIDC_RATE_LIMIT_FAIL_CLOSED=false; pnpm test' +``` + +- **验证结果**:服务端 160 通过、11 跳过、0 失败;前端 10/10 通过。 diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index 4a92369..939e75c 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -1551,9 +1551,14 @@ export function createInteractionRouter( throw error; } }); + const requestedScopes = + typeof loginDetails.params?.scope === "string" + ? new Set(loginDetails.params.scope.split(/\s+/).filter(Boolean)) + : new Set(); if ( - !principal.email || - (config.emailVerificationEnabled && !principal.emailVerified) + requestedScopes.has("email") && + (!principal.email || + (config.emailVerificationEnabled && !principal.emailVerified)) ) { await store.saveInteractionLogin(uid, { principal, diff --git a/test/oidc-op.test.ts b/test/oidc-op.test.ts index 6c3f5eb..c4bf3bb 100644 --- a/test/oidc-op.test.ts +++ b/test/oidc-op.test.ts @@ -576,7 +576,15 @@ async function authorizeThroughProfile( account: TEST_LOGIN_ACCOUNT, password: TEST_LOGIN_PASSWORD, }); - assert.equal(login.status, 302); + assert.ok(login.status === 302 || login.status === 303); + if (!scope.split(/\s+/).includes("email")) { + return { + response: login, + codeVerifier: verifier, + profileLocation: undefined, + interactionUid: extractInteractionUid(interactionLocation), + }; + } assert.match( login.headers["location"] as string, /\/interaction\/.+\/profile/, @@ -696,12 +704,13 @@ async function openLoginInteraction( agent: any, state = "login-state-1", headers?: Record, + scope = "openid profile email", ) { const authorize = await withHeaders(agent.get("/auth"), headers).query({ client_id: "demo-site", redirect_uri: TEST_REDIRECT_URI, response_type: "code", - scope: "openid profile", + scope, prompt: "consent", state, nonce: "nonce-login-1", @@ -1157,7 +1166,7 @@ test("seeded demo client is confidential web client", async () => { }); test("public client without explicit refresh confirmation does not receive refresh token", async () => { - const { app, state, emailSender } = await createTestApp(); + const { app, state } = await createTestApp(); await upsertPublicNoneClient(state, "public-unconfirmed", { allowRefreshTokenForPublicClient: false, }); @@ -1187,31 +1196,9 @@ test("public client without explicit refresh confirmation does not receive refre account: TEST_LOGIN_ACCOUNT, password: TEST_LOGIN_PASSWORD, }); - assert.equal(login.status, 302); - const profileLocation = login.headers["location"] as string; - const profilePage = await agent.get(profileLocation); - const sendCode = await agent - .post(profileLocation) - .type("form") - .send({ - csrf: extractCsrf(profilePage.text), - action: "send_code", - email: "demo@example.com", - }); - const sentCode = emailSender.latestCode( - extractInteractionUid(interactionLocation), - "demo@example.com", - ); - assert.equal(typeof sentCode, "string"); - const profile = await agent - .post(profileLocation) - .type("form") - .send({ - csrf: extractCsrf(sendCode.text), - action: "verify_code", - code: sentCode, - }); - const consentPageHtml = await followToConsentPage(agent, profile); + assert.ok(login.status === 302 || login.status === 303); + assert.doesNotMatch(login.headers["location"] as string, /\/profile/); + const consentPageHtml = await followToConsentPage(agent, login); const consent = await agent .post(normalizeActionPath(extractConsentAction(consentPageHtml))) .type("form") @@ -2004,6 +1991,42 @@ test("profile routes reject requests without the interaction session cookie", as await state.store.close(); }); +test("login without email scope skips profile completion", async () => { + const { app, state, emailSender } = await createTestApp(); + const agent = request.agent(app); + const { interactionLocation, loginPage } = await openLoginInteraction( + agent, + "login-without-email-scope", + undefined, + "openid profile", + ); + const login = await agent + .post(`${interactionLocation}/login`) + .type("form") + .send({ + csrf: extractCsrf(loginPage.text), + account: TEST_LOGIN_ACCOUNT, + password: TEST_LOGIN_PASSWORD, + }); + + assert.ok(login.status === 302 || login.status === 303); + assert.doesNotMatch(login.headers["location"] as string, /\/profile/); + const callback = await followToRedirectUriOrigin( + agent, + login, + TEST_REDIRECT_URI, + ); + const callbackUrl = new URL(callback); + assert.equal( + callbackUrl.searchParams.get("state"), + "login-without-email-scope", + ); + assert.equal(typeof callbackUrl.searchParams.get("code"), "string"); + assert.equal(emailSender.sentVerifications.length, 0); + + await state.store.close(); +}); + test("interactive login treats upstream outages as retryable 503 without consuming the failure budget", async () => { const { app, state } = await createTestApp({ // Isolate the failure-bucket behavior from the separate attempt limiter. diff --git a/web/src/app/providers/access-control-provider.ts b/web/src/app/providers/access-control-provider.ts index 3fd44ec..2a5e15d 100644 --- a/web/src/app/providers/access-control-provider.ts +++ b/web/src/app/providers/access-control-provider.ts @@ -1,5 +1,5 @@ import type { AccessControlProvider } from "@refinedev/core"; -import { request } from "../../api/client"; +import { request, setCsrfToken } from "../../api/client"; import type { AuthContext, Project, ProjectAction } from "../../api/types"; // Dynamic active project reference for global access control @@ -20,6 +20,7 @@ export const accessControlProvider: AccessControlProvider = { if (!currentUser) { try { const data = await request("/auth/context"); + setCsrfToken(data.csrfToken); if (data.authenticated) { currentUser = data.user; } else { diff --git a/web/src/app/providers/auth-provider.test.ts b/web/src/app/providers/auth-provider.test.ts new file mode 100644 index 0000000..570e648 --- /dev/null +++ b/web/src/app/providers/auth-provider.test.ts @@ -0,0 +1,40 @@ +import { expect, test, vi } from "vitest"; + +vi.mock("../../api/client", () => ({ + request: vi.fn(), + setCsrfToken: vi.fn(), +})); + +import { request, setCsrfToken } from "../../api/client"; +import { authProvider } from "./auth-provider"; + +test("refreshes the anonymous CSRF context before submitting a login", async () => { + const context = { authenticated: false as const, csrfToken: "fresh-csrf" }; + const authenticated = { + authenticated: true as const, + csrfToken: "session-csrf", + user: { + subjectId: "subj_test", + preferredUsername: "test", + displayName: "Test", + isAdmin: false, + }, + clientSecretPolicy: { defaultGraceSeconds: 3600, maxGraceSeconds: 7200 }, + }; + vi.mocked(request) + .mockResolvedValueOnce(context) + .mockResolvedValueOnce(authenticated); + + const result = await authProvider.login?.({ + account: "account", + password: "password", + }); + + expect(result).toMatchObject({ success: true, redirectTo: "/projects" }); + expect(request).toHaveBeenNthCalledWith(1, "/auth/context"); + expect(setCsrfToken).toHaveBeenNthCalledWith(1, "fresh-csrf"); + expect(request).toHaveBeenNthCalledWith(2, "/auth/login", { + method: "POST", + body: JSON.stringify({ account: "account", password: "password" }), + }); +}); diff --git a/web/src/app/providers/auth-provider.ts b/web/src/app/providers/auth-provider.ts index 503536c..c19c6f4 100644 --- a/web/src/app/providers/auth-provider.ts +++ b/web/src/app/providers/auth-provider.ts @@ -5,6 +5,11 @@ import type { AuthContext } from "../../api/types"; export const authProvider: AuthProvider = { login: async ({ account, password }) => { try { + // The login CSRF token is bound to the anonymous nonce cookie. Refresh it + // immediately before submitting so a stale page or an earlier provider + // call cannot submit without the matching header. + const context = await request("/auth/context"); + setCsrfToken(context.csrfToken); const data = await request("/auth/login", { method: "POST", body: JSON.stringify({ account, password }), diff --git a/web/vite.config.ts b/web/vite.config.ts index 2efee50..b5aa853 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; export default defineConfig({ root: resolve(import.meta.dirname), base: "/manage/", - publicDir: resolve(import.meta.dirname, "src/assets"), + publicDir: resolve(import.meta.dirname, "public"), build: { outDir: resolve(import.meta.dirname, "../dist/management"), emptyOutDir: true, From de6b5d350377b61d9ab7406579091e3d2feb8421 Mon Sep 17 00:00:00 2001 From: UE-DND <100979820+UE-DND@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:06:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=B9=20=E7=A7=BB=E9=99=A4=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=9A=84=E8=81=94=E8=B0=83=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- INTEGRATION_BUGS.md | 150 -------------------------------------------- 1 file changed, 150 deletions(-) delete mode 100644 INTEGRATION_BUGS.md diff --git a/INTEGRATION_BUGS.md b/INTEGRATION_BUGS.md deleted file mode 100644 index 56aa8e6..0000000 --- a/INTEGRATION_BUGS.md +++ /dev/null @@ -1,150 +0,0 @@ -# 联调缺陷与环境问题记录 - -> 最后更新:2026-07-16 -> -> 范围:本地 Docker 开发栈(`oidc-op`、PostgreSQL、Redis)及 React 管理后台。 -> 本文不记录会话 Cookie、CSRF token、账号、密码、客户端密钥或邮件通道密钥。 - -## 状态一览 - -| 编号 | 类型 | 状态 | 摘要 | -| ------- | ------------- | ---------- | ----------------------------------------------------------------- | -| INT-001 | 前端缺陷 | 已修复 | 管理台登录出现 `CSRF validation failed`。 | -| INT-002 | 开发栈缺陷 | 待修复 | 管理端“重启服务”会使 `pnpm dev` 容器中的 API 子进程停止且不恢复。 | -| INT-003 | 配置状态 | 已验证 | 邮件未配置时 readiness degraded,配置并重启后恢复 ready。 | -| INT-004 | 交接信息问题 | 已澄清 | 交接文本中的资源 ID 与数据库实际 ID 不一致。 | -| INT-005 | 本机环境问题 | 有替代方案 | 宿主 Windows 不能直接执行 `vitest`,容器内可正常执行。 | -| INT-006 | OIDC 流程缺陷 | 已修复 | 未请求 `email` scope 仍强制进入邮箱补充流程。 | -| INT-007 | 静态资源缺陷 | 已修复 | Vite 未将 favicon 复制到管理台构建目录。 | -| INT-008 | 测试环境问题 | 有替代方案 | 容器联调环境变量污染默认测试运行。 | - -## INT-001:管理台登录 CSRF 校验失败 - -- **现象**:登录页提交账号密码后显示 `ApiError: CSRF validation failed`。 -- **影响**:管理后台无法建立会话,阻断客户端创建、Revision 审核和系统设置操作。 -- **根因**: - 1. `access-control-provider` 请求 `/auth/context` 后未调用 `setCsrfToken`; - 2. `authProvider.login` 直接提交 `/auth/login`,没有保证当前内存中的 token 与匿名 CSRF nonce Cookie 对应。 - -服务端登录接口要求双提交校验:匿名请求先由 `/auth/context` 设置 -`cqut_manage_csrf` Cookie 并返回同一 nonce 绑定的 token,随后登录请求必须在 -`X-CSRF-Token` 请求头中携带该 token。 - -### 修复 - -- `web/src/app/providers/access-control-provider.ts`:读取认证上下文后同步 CSRF token。 -- `web/src/app/providers/auth-provider.ts`:登录提交前强制刷新认证上下文并同步 token。 -- `web/src/app/providers/auth-provider.test.ts`:新增回归测试,断言请求顺序为 - `/auth/context` → 设置 token → `/auth/login`。 - -### 验证证据 - -- 正确 Cookie + `X-CSRF-Token` 的空凭据请求返回 `401`,而非 CSRF `400`,证明服务端握手有效。 -- 容器内 `pnpm test:ui`:10/10 通过。 -- 容器内 `pnpm lint`:通过。 - -### 复测步骤 - -1. 对 `http://127.0.0.1:3003` 执行硬刷新(Ctrl+F5)。 -2. 使用管理台登录页提交有效学校账号。 -3. 预期不再出现 CSRF 错误;若浏览器仍缓存旧前端资源,删除该站点的 - `cqut_manage_csrf` Cookie 后刷新再试。 - -## INT-002:开发模式下应用内重启使 API 不可用 - -- **现象**:`POST /api/management/settings/runtime-policy/restart` 返回 `202` 后, - 3003 端口拒绝连接,`/health/live` 长时间无法恢复。 -- **影响**:运行策略写入后,在 Docker 开发栈中无法依赖管理端按钮使新策略加载; - 此后所有 API 联调中断。 -- **复现条件**:Compose 服务以 `pnpm dev` 启动,内部使用 - `tsx watch --env-file=deploy/.env src/main.ts`。 -- **已观察证据**:重启回调对服务子进程发送 `SIGTERM`;`tsx watch` 进程及 - `concurrently` 父进程仍在,但不再存在实际监听 3003 的应用进程。 - -### 当前规避措施 - -使用 Compose 管理的重启恢复服务: - -```powershell -docker compose -f deploy/docker-compose.yml restart oidc-op -``` - -### 建议修复方向 - -在开发模式中禁用该应用内重启入口并提示使用 Compose 重启,或调整进程拓扑, -确保退出服务进程会使容器主进程退出并由 Docker 的 `restart: unless-stopped` -重新创建服务。修复后需验证 `202` 返回后 `/health/live` 自动恢复。 - -## INT-003:邮件未配置导致 readiness 为 degraded(非缺陷) - -- **现象**:`/health/live` 返回 `200`,但 `/health/ready` 返回 `503`,响应中包含 - `email: "unconfigured"`。 -- **判断**:这是阶段 5 邮件通道尚未配置时的预期就绪检查结果,不是服务存活故障。 -- **影响**:Docker 健康检查当前使用 `/health/ready`,因此 `oidc-op` 容器会显示 - `unhealthy`,即使 API 仍可提供服务。 -- **阶段 5 结果**:SMTP 配置加密入库并通过测试邮件和验证码验证;Compose - 重启后运行策略 `version=4`、`loadedVersion=4`、`restartRequired=false`, - `/health/ready` 从 `503` 恢复为 `200`。 - -## INT-004:交接文本中的联调资源 ID 与数据库不一致 - -- **现象**:按交接文本中的 `proj_...`、`client_...` 查询时未找到资源,最初误判为 - 数据卷缺失。 -- **数据库证据**:资源实际存在,真实 ID 为: - - 项目:`project_17jJ6LJfu8gm6-Mx3hEyAnBX` - - Web:`client_8rr5uLOjZ9k9b1M43mM_Yrz7` - - SPA:`client_A6M8Kgg_-C99EdphCvOZUkll` -- **判断**:数据未丢失,问题来自阶段交接文本中的 ID 与数据库不一致。 - -## INT-005:宿主 Windows 的前端测试可执行文件不可用 - -- **现象**:宿主执行 `pnpm test:ui` 时提示 `vitest is not recognized`。 -- **影响**:不能直接使用宿主的 `node_modules` 执行前端测试。 -- **规避措施**:使用运行中的 Linux 开发容器: - -```powershell -docker compose -f deploy/docker-compose.yml exec -T oidc-op pnpm test:ui -docker compose -f deploy/docker-compose.yml exec -T oidc-op pnpm lint -``` - -- **后续动作**:如需恢复宿主验证能力,重新为 Windows 安装匹配平台的依赖;注意不要影响 - Compose 中用于隔离 Linux 原生依赖的 `/app/node_modules` 匿名卷。 - -## INT-006:未请求 email scope 仍强制补充邮箱 - -- **现象**:首次登录主体尚无已验证邮箱时,即使授权请求仅包含 - `openid profile student`,登录后仍跳转到邮箱补充和验证码流程。 -- **影响**:不需要邮箱声明的客户端也被邮件通道阻断,使阶段 4 OIDC 流程错误依赖阶段 5 - 邮件配置。 -- **根因**:`src/routes/interactions.ts` 只检查主体邮箱状态,没有检查当前授权请求是否包含 - `email` scope。 - -### 修复 - -- 仅当当前请求包含 `email` scope,且主体缺少邮箱或需要验证时,才进入 `/profile`。 -- 新增回归测试:首次主体请求 `openid profile` 时,登录后直接继续授权,且不发送验证码。 - -## INT-007:管理台 favicon 未进入构建目录 - -- **现象**:访问 `/favicon.svg` 时服务日志出现 - `ENOENT: /app/dist/management/favicon.svg`。 -- **根因**:`web/vite.config.ts` 将 `publicDir` 指向 `web/src/assets`,但 favicon 位于 - `web/public/favicon.svg`。 -- **修复**:将 Vite `publicDir` 改为 `web/public`,构建后应生成 - `dist/management/favicon.svg`。 - -## INT-008:容器联调环境变量污染测试运行 - -- **现象**:直接在 `oidc-op` 容器执行 `pnpm test` 时,测试继承真实 - `DATABASE_URL`、`REDIS_URL` 和 `OIDC_RATE_LIMIT_FAIL_CLOSED=true`: - - 测试密钥无法解密联调库中的运行策略,产生级联失败; - - 仅清除数据库和 Redis 地址后,fail-closed 又使大量请求返回 `503`。 -- **判断**:这是测试启动环境隔离问题,不是测试用例回归。 -- **可靠命令**: - -```powershell -docker compose -f deploy/docker-compose.yml exec -T oidc-op sh -lc ` - 'unset DATABASE_URL REDIS_URL; export OIDC_RATE_LIMIT_FAIL_CLOSED=false; pnpm test' -``` - -- **验证结果**:服务端 160 通过、11 跳过、0 失败;前端 10/10 通过。