From c2f0b1549ea858871cd80a7303fddfe5118a321d Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sat, 27 Jun 2026 03:18:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20OIDC=20=E7=99=BB=E5=85=A5=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=EF=BC=88email=20fallback=20/=20prompt=3Dlogin=20/=20l?= =?UTF-8?q?ogout=20=E9=80=A3=E5=8B=95=EF=BC=89+=20=E7=9C=9F=E8=BD=89?= =?UTF-8?q?=E6=AA=94=E9=8F=88=E8=B7=AF=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過, 讓登入/換帳號可用;另補真轉檔服務的整合 e2e。 OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過): - email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發 sub/name)→ A7 email 必填擋住登入。callback email 空時用 @noemail.visiona.local placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫 - prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關) - logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用 隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。 config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地) 真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集): - real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result) - real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS) 交接檔(給對應團隊根治): - mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory) - converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth 全程 Reviewer 審查 + 對 stage 真環境驗證。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../converter-promote-oauth-handoff.md | 183 +++++++ .../04-architecture/mc-email-claim-handoff.md | 189 +++++++ visionA-backend/.env.example | 18 + visionA-backend/cmd/api-server/main.go | 6 +- visionA-backend/internal/api/api.go | 10 + visionA-backend/internal/api/oidc_auth.go | 114 +++- .../internal/api/oidc_auth_test.go | 204 ++++++- visionA-backend/internal/config/config.go | 35 ++ visionA-backend/internal/config/load.go | 5 + .../conversion/real_chain_e2e_test.go | 506 ++++++++++++++++++ .../conversion/real_converter_e2e_test.go | 491 +++++++++++++++++ visionA-backend/internal/oidc/oidc.go | 11 + visionA-backend/internal/oidc/provider.go | 12 +- .../internal/oidc/provider_test.go | 50 +- .../src/stores/auth-store.test.ts | 109 ++++ visionA-frontend/src/stores/auth-store.ts | 133 ++++- 16 files changed, 2057 insertions(+), 19 deletions(-) create mode 100644 docs/autoflow/04-architecture/converter-promote-oauth-handoff.md create mode 100644 docs/autoflow/04-architecture/mc-email-claim-handoff.md create mode 100644 visionA-backend/internal/conversion/real_chain_e2e_test.go create mode 100644 visionA-backend/internal/conversion/real_converter_e2e_test.go diff --git a/docs/autoflow/04-architecture/converter-promote-oauth-handoff.md b/docs/autoflow/04-architecture/converter-promote-oauth-handoff.md new file mode 100644 index 0000000..ac4957d --- /dev/null +++ b/docs/autoflow/04-architecture/converter-promote-oauth-handoff.md @@ -0,0 +1,183 @@ +# 交接檔:轉檔服務 promote 401 根因 + 修法(OAuth client 認證送法) + +> 對象:維護 `kneron_model_converter` 的工程師 +> 來源:visionA 端(Orchestrator 實測定位) +> 狀態:根因已精確定位並實測排除其他可能,待轉檔服務側修正 +> 最後更新:2026-06-22 +> 語言:zh-TW + +--- + +## 0. 一句話結論 + +轉檔服務的 OAuth client 用 **HTTP Basic Auth** 把 client 認證送給 Member Center(MC,OpenIddict),但 **MC 拒絕 Basic Auth、只接受 `client_id` / `client_secret` 放在 POST form body**(OAuth2 的 `client_secret_post` 方式)。把認證從 Basic header 改成 form body 即可解決。憑證、scope、endpoint、真轉檔全部正常,不需要動。 + +--- + +## 1. 現象 + +visionA「轉檔 → 進模型庫」鏈路的最後一步 promote 一直失敗: + +- visionA 呼叫轉檔服務 `POST /api/v1/jobs/{id}/promote` +- 回應 `500`: + ```json + { "error": { "code": "internal_error", "message": "promote 過程發生未預期錯誤" } } + ``` + +### scheduler log 證據(依序) + +``` +oauth.token_endpoint_error scope:"files:upload.write" status:401 error_code:"invalid_client" +promote.faa_put_failed OAuthClientError 401 +→ 對外回 500 +``` + +也就是說:promote 階段去 MC 換 service token 時,MC 回 `401 invalid_client`,導致後續 FAA PUT 拿不到 token,最終轉檔服務對 visionA 回 500。 + +--- + +## 2. 精確根因 + +`apps/task-scheduler/src/auth/oauthClient.js` 取 token 時,把 client 認證放在 **HTTP Basic Auth header**: + +``` +Authorization: Basic base64(client_id:client_secret) +``` + +而 body 只帶 `grant_type` / `scope` / `audience`。 + +MC(OpenIddict)**不接受 Basic Auth 形式的 client 認證**,只接受 `client_id` / `client_secret` 放在 `application/x-www-form-urlencoded` 的 POST body(即 OAuth2 spec 的 `client_secret_post` token endpoint auth method)。 + +### 實測對照表(同一組 client_id + 同一個 secret,打同一個 MC endpoint) + +| 認證送法 | 請求內容 | MC 回應 | +|---------|---------|---------| +| **form body**(`client_secret_post`) | body 含 `client_id` / `client_secret` / `grant_type` / `scope`,**無** Authorization header | ✅ **成功拿到 token**。JWT 解出 `scope: files:upload.write`、`aud: file_access_api` | +| **HTTP Basic Auth**(`client_secret_basic`) | `Authorization: Basic base64(client_id:client_secret)`,body 只有 grant_type/scope | ❌ `401` `invalid_client`「The specified client credentials are invalid.」 | + +- client_id:`4242ba63099d4f318dd3f143d27ef4c5` +- MC token endpoint:`https://stage-9527.innovedus.com:7850/oauth/token` +- 兩種方式用的是**完全相同**的 client_id + secret,差別只在「認證放哪」。 + +> 結論:`invalid_client` 不是「憑證錯」,是「MC 不認 Basic Auth 這種送法」。OpenIddict 預設行為即如此(client 須在 client registration 設定允許的 auth method;此 client 走 form body)。 + +--- + +## 3. 已排除的其他可能(請不要往這些方向查,會浪費時間) + +| 懷疑點 | 結論 | 證據 | +|--------|------|------| +| 憑證錯(client_id / secret 不對) | ❌ 不是 | 轉檔服務 container 內的 `KNERON_CONVERTER_CLIENT_SECRET` 與 warrenchen 給的 secret **sha256 完全一致**;且 form body 方式用同一組憑證能成功換到 token | +| scope 沒授權 | ❌ 不是 | MC 已授權此 client `files:upload.write`;form body 方式換到的 token JWT 內 `scope` 即含 `files:upload.write` | +| token endpoint URL 設錯 | ❌ 不是 | 轉檔服務設的 `MEMBER_CENTER_TOKEN_URL` 與實測成功的 URL 相同(`.../oauth/token`) | +| FAA audience 錯 | ❌ 不是 | form body 換到的 token `aud: file_access_api`,與預期一致 | +| 真轉檔(KTC)有問題 | ❌ 不是 | 真 KTC 轉檔已成功,產出 nef 真檔(約 800KB);promote 卡的純粹是 OAuth 換 token 那一步 | + +**唯一變因就是 client 認證的送法(Basic header vs form body)。** + +--- + +## 4. 要改的位置與改法 + +### 檔案 +`apps/task-scheduler/src/auth/oauthClient.js` + +### 當前 code 位置(已確認,行號為現況) + +1. **`buildBasicAuthHeader()`**(lines 91–95)— 產生 `Basic base64(id:secret)`: + ```js + function buildBasicAuthHeader(clientId, clientSecret) { + const raw = `${clientId}:${clientSecret}`; + return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`; + } + ``` + +2. **`_fetchToken()` 內組 body / headers 的地方**(lines 299–309): + ```js + const body = new URLSearchParams({ + grant_type: 'client_credentials', + scope, + audience: config.faaAudience, + }).toString(); + + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + Authorization: buildBasicAuthHeader(config.clientId, config.clientSecret), // ← 問題在這 + }; + ``` + +3. **檔頭 design 註解**(lines 19–24)目前寫死「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 —— 改完一併更新,避免下一個人又改回去。 + +### 改法(最小修正:Basic header → form body) + +把 `client_id` / `client_secret` 從 Basic header 移進 body,移除 `Authorization` header: + +```js +const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: config.clientId, // ← 新增 + client_secret: config.clientSecret, // ← 新增 + scope, + audience: config.faaAudience, +}).toString(); + +const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + // 不再送 Authorization Basic header +}; +``` + +- `buildBasicAuthHeader()` 改完後若無其他使用處可移除(連同 `_internals` 的 export 與相關測試)。 +- 這是 OAuth2 spec(RFC 6749 §2.3.1)允許的兩種 client 認證方式之一(`client_secret_post`),MC 接受這種。 + +### 安全注意(務必保留) + +- 原本「**絕不**把 `client_secret` / token / Authorization 內容寫入 log」的約束**必須維持**。`client_secret` 移進 body 後,一樣**不能**出現在任何 log(注意:`URLSearchParams` 字串、`body` 變數、錯誤訊息都不可被 log 出來)。 +- 檔內 `logEvent()` 目前不 log body,維持即可;新增/修改時別不小心把 `body` 帶進 log fields。 +- `tryParseOauthErrorBody` / error log 維持只揭露 `status` + 標準 `error_code`,不要為了 debug 把 request body dump 出來。 + +--- + +## 5. 改完怎麼驗 + +### 驗法 A:直接 curl MC(最快,先確認 MC 端接受 form body) + +```bash +curl -s -X POST 'https://stage-9527.innovedus.com:7850/oauth/token' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'Accept: application/json' \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=4242ba63099d4f318dd3f143d27ef4c5' \ + --data-urlencode 'client_secret=<從環境變數帶入,勿貼進 shell history>' \ + --data-urlencode 'scope=files:upload.write' \ + --data-urlencode 'audience=' +``` +預期:回 `200` + `{ access_token, token_type, expires_in, ... }`。把 `access_token` 丟 jwt.io 解,應看到 `scope: files:upload.write`、`aud: file_access_api`。 + +> 安全提醒:`client_secret` 不要直接貼在指令裡(會進 shell history)。用 `--data-urlencode "client_secret=$KNERON_CONVERTER_CLIENT_SECRET"` 從環境變數帶。 + +### 驗法 B:重跑 promote(端到端) + +改完部署後,由 visionA 重新觸發一次轉檔 → promote: + +- scheduler log 應從 `oauth.token_endpoint_error status:401` 變成 `oauth.token_obtained scope:"files:upload.write"`。 +- promote 對 visionA 回 `200`(不再 500)。 +- nef 成功推進 FAA / 模型庫。 + +--- + +## 6. 小提醒(可選,非必要) + +- 若想更穩健,可讓 `oauthClient` 用 config 支援兩種 auth method(`client_secret_basic` / `client_secret_post`)切換,預設走 `post`。但**目前最簡單、足夠解決問題的做法就是直接改成 form body**,不需要為此加複雜度。 +- 改完記得更新 `oauthClient.js` 檔頭 design 註解(lines 19–24)說明改用 `client_secret_post`,否則註解與實作不一致,未來容易被誤改回 Basic。 + +--- + +## 附錄:與我描述略有出入的實際 code 結構(供對方校正) + +- `buildBasicAuthHeader()` 實際在 **lines 91–95**(描述為 ~91–94)。 +- 認證 header 實際送出處在 **lines 305–309(headers 物件)/ 316–321(fetch)**,`Authorization` 設定在 **line 308**(描述為 ~300–308,大致吻合)。 +- **額外發現**:body 除了 `grant_type` / `scope`,還帶了 `audience: config.faaAudience`(line 302)。改成 form body 時,是在這個**既有 body** 上新增 `client_id` / `client_secret`,不是憑空新建 body。 +- 檔頭 lines 19–24 的 design 註解明文寫「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 當理由 —— 這是當初的設計決策,改 code 時要連這段註解一起改掉。 diff --git a/docs/autoflow/04-architecture/mc-email-claim-handoff.md b/docs/autoflow/04-architecture/mc-email-claim-handoff.md new file mode 100644 index 0000000..45d4cdf --- /dev/null +++ b/docs/autoflow/04-architecture/mc-email-claim-handoff.md @@ -0,0 +1,189 @@ +# 交接文件:MC id_token 缺 email claim(導致 visionA 建 user 500) + +## 作者:Architect Agent(visionA 端) +## 對象:Member Center 團隊 +## 狀態:待 MC 修復 +## 最後更新:2026-06-26 + +--- + +## 1. 一句話總結 + +**MC(Member Center)發出的 OIDC id_token 永遠沒有 `email` claim,根因是 MC 用 ASP.NET Identity 的「預設」`UserClaimsPrincipalFactory`(它只放 NameIdentifier / Name / Role,不放 email),導致 visionA 拿到空 email 後建 user 失敗回 500。** + +與 user 的 `EmailConfirmed` 狀態無關 —— 預設 factory 根本不把 email 放進 principal,不管 confirmed 與否。 + +--- + +## 2. 現象(visionA 端觀察到的) + +1. 使用者透過 MC 登入(OIDC Authorization Code Flow)。 +2. visionA callback 拿到 id_token,解析後 **`email` claim 為空 / 不存在**。 +3. visionA 端 provision user 失敗: + + ``` + oidc.callback: provision user failed error:"user: Upsert requires non-empty email" + ``` + +4. visionA 回 HTTP 500:`failed to provision user`。 + +visionA 端 email 為**必填**(fail-closed):沒有 email 就無法建立 / 更新 user,因此 id_token 缺 email 直接導致登入失敗。 + +--- + +## 3. 精確根因(已深入 MC codebase 坐實) + +### 3.1 核心:依賴 ASP.NET Identity 預設的 ClaimsPrincipalFactory + +MC 在發 token 前,用 `SignInManager.CreateUserPrincipalAsync(user)` 建立 `ClaimsPrincipal`,但**全 codebase 沒有自訂 `IUserClaimsPrincipalFactory`**(grep `IUserClaimsPrincipalFactory` / `UserClaimsPrincipalFactory` 在 `src/` 下 0 命中)。 + +ASP.NET Identity(8.0.11)的預設 `UserClaimsPrincipalFactory` 只會放入: +- `ClaimTypes.NameIdentifier`(= user Id,對應 OIDC `sub`) +- `ClaimTypes.Name`(= UserName) +- 使用者的 Role claims(若有) + +**它不會放入 `email` claim。** 所以無論下游 scope / destination 怎麼設定,principal 裡根本沒有 email claim 可發。 + +### 3.2 證據(檔案 + 行號,當前 MC code 位置) + +| 檔案 | 行號 | 內容 | 問題 | +|------|------|------|------| +| `src/MemberCenter.Api/Controllers/OAuthController.cs` | 40 | `var principal = await _signInManager.CreateUserPrincipalAsync(user);` | 之後(line 42-45)只做 destination 路由,**沒有手動加 email claim** | +| `src/MemberCenter.Api/Controllers/TokenController.cs` | 60 | `var principal = await _signInManager.CreateUserPrincipalAsync(user);`(password grant 分支) | 同樣,line 65-68 只做 destination 路由,**沒有手動加 email claim** | +| `src/MemberCenter.Api/Extensions/ClaimsExtensions.cs` | 26-27 | `Name or Email => { AccessToken, IdentityToken }` | 路由邏輯**已寫好**把 email 送進 IdentityToken,**但前提是 principal 裡已有 email claim —— 實際沒有**(這是最迷惑的點:destination 規則寫對了,但 source claim 從沒被加進 principal) | +| `src/MemberCenter.Api/Program.cs` | 30-41 | `AddIdentity<...>().AddEntityFrameworkStores<...>().AddDefaultTokenProviders()` | 無 `AddClaimsPrincipalFactory<...>`,**沿用預設 factory**;也沒有設定自訂 EmailClaimType | + +### 3.3 關鍵釐清:destination 規則 ≠ claim 來源 + +`ClaimsExtensions.GetDestinations()`(line 22-30)的作用是「**如果**有 email claim,把它路由到 IdentityToken」。但它無法「製造」email claim。 + +可以把這想成兩段: +1. **產生 claim**(誰負責把 email 放進 principal)→ **目前沒人做**(缺的就是這段) +2. **路由 claim**(決定 claim 進 access_token 還是 id_token)→ 已正確實作(ClaimsExtensions) + +第 1 段缺失,第 2 段再正確也沒用。 + +--- + +## 4. 已排除的可能(附 DB / 設定查證,免 MC 團隊走冤枉路) + +| 懷疑點 | 是否為根因 | 查證結果 | +|--------|-----------|---------| +| visionA 沒要 email scope | ❌ 不是 | visionA 的 `DefaultScopes` 含 `email`,已驗證 | +| client 沒被授權 email scope | ❌ 不是 | MC 的 `OpenIddictApplications` 中 visionA client `b8093fea1a504a5d8f0e04bee9f78f2e` 的 Permissions 含 `scp:email`,已查 DB 確認 | +| 使用者沒有 email | ❌ 不是 | user `b5332e51` 的 `Email = jim800121.chen@gmail.com`,已查 DB 確認。(且 `Program.cs` line 33 `RequireUniqueEmail = true`,MC 所有 user 必有 email) | +| `EmailConfirmed = false` 導致不發 email | ❌ 不是 | 該 user `EmailConfirmed = false`,但**根因是預設 factory 根本不放 email claim,與 confirmed 與否無關**。即使 confirmed = true,預設 factory 仍不放 email | + +**結論**:scope、client 授權、user email、EmailConfirmed 全部正常 / 不相關。唯一缺口是 §3 的 claims factory。 + +--- + +## 5. 修法(給 MC 團隊選) + +### 方案 (a)【推薦】自訂 `UserClaimsPrincipalFactory` + +集中、乾淨,所有發 token 路徑(OAuthController / TokenController)一次涵蓋。 + +1. 在 `src/MemberCenter.Infrastructure/Identity/` 新建 `CustomUserClaimsPrincipalFactory`: + + ```csharp + using System.Security.Claims; + using Microsoft.AspNetCore.Identity; + using Microsoft.Extensions.Options; + using OpenIddict.Abstractions; + + namespace MemberCenter.Infrastructure.Identity; + + public class CustomUserClaimsPrincipalFactory + : UserClaimsPrincipalFactory + { + public CustomUserClaimsPrincipalFactory( + UserManager userManager, + RoleManager roleManager, + IOptions options) + : base(userManager, roleManager, options) + { + } + + protected override async Task GenerateClaimsAsync(ApplicationUser user) + { + var identity = await base.GenerateClaimsAsync(user); + + if (!string.IsNullOrWhiteSpace(user.Email)) + { + // OpenIddict 用的 claim type 為 "email"(OpenIddictConstants.Claims.Email) + identity.AddClaim(new Claim(OpenIddictConstants.Claims.Email, user.Email)); + } + + // 建議連 email_verified 一起放(見 §5.3) + identity.AddClaim(new Claim( + OpenIddictConstants.Claims.EmailVerified, + user.EmailConfirmed ? "true" : "false", + ClaimValueTypes.Boolean)); + + return identity; + } + } + ``` + +2. 在 `Program.cs`(line 30-41 的 Identity 設定鏈)註冊,取代預設 factory: + + ```csharp + builder.Services + .AddIdentity(options => { /* 既有設定不變 */ }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders() + .AddClaimsPrincipalFactory(); // ← 新增這行 + ``` + +3. **destination 路由不用改** —— `ClaimsExtensions.GetDestinations()`(line 26-27)已經會把 `email` 與 `email_verified`(若要進 id_token 需確認 case,見下)送對地方。 + - ⚠️ 注意:目前 `GetDestinations` 只對 `Name` / `Email` 回 IdentityToken。`email_verified` 不在其中,會只進 access_token。若希望 `email_verified` 也進 id_token,需在 `ClaimsExtensions.cs` line 26 的 switch 加上 `OpenIddictConstants.Claims.EmailVerified`。 + +### 方案 (b)【快速】在兩個 Controller 手動加 email claim + +較分散(兩處都要改),但改動最小。 + +- `OAuthController.cs` line 40 之後、line 42 的 foreach 之前插入: + + ```csharp + if (!string.IsNullOrWhiteSpace(user.Email) && + !principal.HasClaim(c => c.Type == OpenIddictConstants.Claims.Email)) + { + ((ClaimsIdentity)principal.Identity!).AddClaim( + new Claim(OpenIddictConstants.Claims.Email, user.Email)); + } + ``` + +- `TokenController.cs` line 60 之後、line 65 的 foreach 之前插入相同邏輯(password grant 分支)。 +- ⚠️ 缺點:refresh token grant(line 73-83)沿用既有 principal,若首次發 token 沒加 email、refresh 出來的也不會有。方案 (a) 因為在 factory 層處理,refresh 重新驗證時也會走到(取決於 OpenIddict refresh 流程),較不易漏;建議優先 (a)。 + +### 5.3 建議一併加 `email_verified` claim + +`email_verified` 是 OIDC 標準 claim(boolean),值 = `user.EmailConfirmed`。下游(visionA)可據此決定要不要信任 email 或要求驗證。已包含在 §5(a) 範例中。 + +### 5.4 Trade-off:是否要 `EmailConfirmed = true` 才發 email claim?(需 MC + visionA 對齊) + +- **MC 若選擇「只在 confirmed 才發 email」**:未驗證的 user 仍會讓 visionA 拿到空 email → visionA 端仍 500(因 visionA email 必填、fail-closed)。 +- **建議做法**:MC **無條件發 `email` claim**(不管 confirmed),另用 `email_verified` 標記驗證狀態。是否擋未驗證 user 由 visionA 端自行決定(visionA 可選擇接受未驗證 email 先建 user,或讀到 `email_verified=false` 時擋下並引導驗證)。 +- 這個決策需 MC 與 visionA 雙方確認後落地,避免「MC 改了但 visionA 仍 500」。 + +--- + +## 6. 改完怎麼驗 + +1. **MC 自驗**(不需 visionA):用 visionA client 走一次 Authorization Code Flow(或直接 password grant 取 token),拿到 id_token 後到 或自行 decode,確認 payload 含: + - `email`: `` + - `email_verified`: `true` / `false`(若採 §5.3) + - `sub`: `` +2. **端到端驗**:visionA 重新登入 MC,確認: + - callback 拿到的 id_token 含非空 `email` claim + - 不再出現 `oidc.callback: provision user failed error:"user: Upsert requires non-empty email"` + - 登入成功(不再 500)、user 正確建立 / 更新 +3. **回歸**:確認既有 access_token 的 claim / scope 行為沒被破壞(password grant、client_credentials grant 不涉及 user email,理論上不受影響,但建議一併冒煙測試)。 + +--- + +## 7. 附錄:claim type 命名確認事項(請 MC 改時驗證) + +- 範例使用 `OpenIddictConstants.Claims.Email`(值為字串 `"email"`)與 `OpenIddictConstants.Claims.EmailVerified`(`"email_verified"`)。 +- 請確認與 `ClaimsExtensions.GetDestinations()`(line 26)switch 比對的 `OpenIddictConstants.Claims.Email` 為**同一常數**,確保 destination 路由能命中(這點目前 code 已一致,沿用同一常數即可)。 diff --git a/visionA-backend/.env.example b/visionA-backend/.env.example index 7cc8d57..2fb7c3f 100644 --- a/visionA-backend/.env.example +++ b/visionA-backend/.env.example @@ -76,6 +76,24 @@ VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback # prod: https://app.visiona.cloud VISIONA_FRONTEND_URL=http://localhost:3000 +# MC 連動登出入口(讓使用者登出 visionA 後能換帳號) +# 背景:Member Center 不支援標準 OIDC RP-initiated logout(discovery 的 +# end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client 未註冊 +# post_logout_redirect_uri),後端無法乾淨地 302 導向 MC end_session。 +# 唯一瀏覽器可觸發的 MC 登出是 MemberCenter.Web(:7880)的 /account/logout(GET 即 302)。 +# 設定本變數後,POST /api/auth/logout 的 response 會多回 idp_logout(url + method=GET), +# 由前端 navigate(window.location)觸發 MC 登出。 +# **host 必須是 MC Web :7880,不是 MC Api :7850**(:7850 沒 logout 頁、會 404)。 +# **留空=logout 只清 visionA 本地 session、不連動 MC(向下相容)。** +# stage 範例:https://stage-9527.innovedus.com:7880/account/logout +VISIONA_OIDC_LOGOUT_URL= + +# 強制每次登入都重新認證(OIDC prompt=login) +# - true → authorize request 帶 prompt=login,IdP 忽略既有 SSO session、要求重新輸入帳密。 +# - false → 沿用 IdP 既有 session(標準 SSO 體驗),為預設值。 +# stage 想「每次點登入都重新輸入帳密」時設 true;prod 視 UX 決定(通常 false)。 +VISIONA_OIDC_PROMPT_LOGIN=false + # Phase 0.8b 移除:VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET # 服務間認證從 OAuth client_credentials 改為 pre-shared API key(見 ADR-015、conversion.md §3)。 # 兩個 service client env 不再讀取(OIDCConfig.ServiceClientID/Secret struct 欄位 diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index d61ed7d..fd67737 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -173,6 +173,7 @@ func main() { ClientID: cfg.OIDC.ClientID, ClientSecret: cfg.OIDC.ClientSecret, RedirectURL: cfg.OIDC.RedirectURL, + PromptLogin: cfg.OIDC.PromptLogin, }) cancel() if err != nil { @@ -210,6 +211,8 @@ func main() { "client_id", cfg.OIDC.ClientID, "redirect_url", cfg.OIDC.RedirectURL, "frontend_url", cfg.OIDC.PostLoginURL, + "logout_url", cfg.OIDC.LogoutURL, // 空=logout 不連動 MC + "prompt_login", cfg.OIDC.PromptLogin, "cookie_secure", cfg.UserSession.CookieSecure, "absolute_ttl", cfg.UserSession.AbsoluteTTL, "idle_ttl", cfg.UserSession.IdleTTL, @@ -433,7 +436,8 @@ func main() { OIDCProvider: oidcProvider, SessionManager: userSessionMgr, OIDCPostLoginURL: cfg.OIDC.PostLoginURL, - UserStore: userStore, // DB-on FK 收尾 #1:OIDC callback provision users 列 + OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空=logout 只清本地、不連動 MC(向下相容) + UserStore: userStore, // DB-on FK 收尾 #1:OIDC callback provision users 列 }) addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)) diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index eaa33cb..cf04f9a 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -63,6 +63,16 @@ type Deps struct { // 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。 OIDCPostLoginURL string + // OIDCLogoutURL 是「讓使用者連帶登出 IdP(Member Center)session」的入口 URL。 + // + // 背景:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是 + // MemberCenter.Web(:7880)的 /account/logout(GET 即 302)。詳見 config.OIDCConfig.LogoutURL。 + // + // 非空時,logout handler(oidc_auth.go)會在 LogoutResponse 多回 idp_logout 欄位, + // 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。 + // 對齊 cfg.OIDC.LogoutURL(env VISIONA_OIDC_LOGOUT_URL)。 + OIDCLogoutURL string + // UserStore 在 OIDC callback 驗 id_token 成功後 provision(upsert)一筆 users 列 // (DB-on FK 收尾,問題 #1)。D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。 // diff --git a/visionA-backend/internal/api/oidc_auth.go b/visionA-backend/internal/api/oidc_auth.go index 2bad458..6dc9861 100644 --- a/visionA-backend/internal/api/oidc_auth.go +++ b/visionA-backend/internal/api/oidc_auth.go @@ -40,6 +40,42 @@ import ( // 又不會讓 caller 端等到 default HTTP server timeout。 const oidcCallbackTimeout = 30 * time.Second +// fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。 +// +// 背景:Member Center(MC)的 id_token 目前不發 email claim(ASP.NET Identity 預設只發 +// sub/name)。A7 之後 OIDC callback 會把 claims provision 進 users 表,而 users.email 是 +// NOT NULL(防呆檢查保留在 user store 層)。若 email 為空 → Upsert 失敗 → 登入 500。 +// +// 修法:email claim 缺時,用 "@" 當 placeholder: +// - 用 sub 保證唯一(不撞 users 表 lower(email) unique index) +// - 用明顯假的 .local TLD(RFC 6762 保留、不可能是真 email)標記「這不是真 email」 +// +// MC 端根治(讓 id_token 發真 email)交接給 MC 團隊,見 +// docs/autoflow/04-architecture/mc-email-claim-handoff.md。MC 修好後,同一個 sub 再次登入, +// Upsert 的 ON CONFLICT(id) DO UPDATE 會把 placeholder 覆寫成真 email(無需手動清資料)。 +const fallbackEmailDomain = "noemail.visiona.local" + +// isFallbackEmail 回報 email 是否為本系統產生的 fallback placeholder(而非真 email)。 +// +// 供 log / 後續資料盤點判斷「哪些 user 還是 fallback、待 MC 修好後自動覆寫」。 +func isFallbackEmail(email string) bool { + return strings.HasSuffix(email, "@"+fallbackEmailDomain) +} + +// resolveProvisionEmail 決定 provision 進 users 表時要寫入的 email。 +// +// - claimEmail 非空(MC 有發 / 未來修好)→ 直接用真 email、不套 fallback。 +// - claimEmail 為空(MC 現況)→ 用 "@noemail.visiona.local" placeholder。 +// +// 回傳 (email, isFallback);isFallback=true 時 caller 應 log 標記。 +// sub 理論上不會為空(VerifyIDToken 已驗 sub),但防禦性地仍會組出合法 email 字串。 +func resolveProvisionEmail(sub, claimEmail string) (email string, isFallback bool) { + if claimEmail != "" { + return claimEmail, false + } + return sub + "@" + fallbackEmailDomain, true +} + // MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。 // // 故意與 Legacy MeResponse 區分:OIDC 沒有 Roles 概念(雛形),但有 Name。 @@ -50,8 +86,45 @@ type MeResponseOIDC struct { } // LogoutResponse 是 POST /api/auth/logout 的 data payload。 +// +// IDPLogout 為**選填**:只有當 deps.OIDCLogoutURL 有設定時才回傳,告訴前端 +// 「除了清掉 visionA session,還要連帶登出 IdP(Member Center)session」。 +// 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。 type LogoutResponse struct { Success bool `json:"success"` + + // IDPLogout 帶 IdP(MC)登出資訊,供前端觸發 MC 登出(讓使用者能換帳號)。 + // nil 時代表未啟用 MC 連動登出。 + IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"` +} + +// IDPLogoutInfo 描述「如何觸發 IdP(Member Center)登出」。 +// +// 為什麼需要 Method:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是 +// MemberCenter.**Web**(:7880)的 /account/logout。實測 GET 即回 302(清 session + redirect), +// 所以前端可直接 window.location = url 觸發,不必搞 form POST。故 Method="GET"。 +// +// 注意(host):必須指向 MC **Web**(:7880),不是 MC **Api**(:7850)。:7850 沒有 logout 頁 +// (AccountController.Logout 在 Web 端),打 :7850 會 404。MC Web/Api 共享 DataProtection +// (SetApplicationName("MemberCenter")),清 :7880 session 後 :7850 authorize 也視為未登入, +// 下次登入會問帳密、能換帳號。 +// +// 流程交接(前端):拿到 IDPLogout 後 → +// 1. 先(或同時)打 visionA logout(本 response 已清本地 session) +// 2. window.location = URL(GET)觸發 MC :7880 登出 +// 3. ⚠️ stage MC 跑 master 舊版(2026-04-30 image),logout **不支援 returnUrl**, +// 清完 session 會 RedirectToAction("Index","Home") 停在 MC 首頁、不會自動回 visionA。 +// 前端需自行把使用者帶回 visionA 登入(GET /api/auth/login)。 +// +// 注意(跨網域):MC /account/logout 在另一個 origin。GET 導向不受 antiforgery/CORS 限制 +// (瀏覽器直接 navigate),比舊的跨站 form POST 單純。 +type IDPLogoutInfo struct { + // URL 是 IdP(MC Web,:7880)的瀏覽器登出入口。例: + // https://stage-9527.innovedus.com:7880/account/logout + URL string `json:"url"` + + // Method 是觸發登出要用的 HTTP method。MC Web(:7880) GET 即可觸發、固定為 "GET"。 + Method string `json:"method"` } // registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。 @@ -238,10 +311,25 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc { // 在「驗 id_token 成功後、寫 session 之前」provision:fail-closed —— provision 失敗就不發 // session(否則使用者拿到能登入的 cookie 但 DB 沒對應 user,下一個寫入照樣爆,且更難診斷)。 // UserStore 為 nil(最小骨架 / 純 OIDC unit test)→ 略過 upsert。 + // + // email fallback(放寬 email 必填):MC 現況不發 email claim,claims.Email 為空。 + // users.email NOT NULL 的防呆檢查保留在 user store 層;這裡在 provision 前先把空 email + // 補成 "@noemail.visiona.local" placeholder,讓登入能成功。MC 修好後同 sub 再登入, + // Upsert ON CONFLICT(id) DO UPDATE 會自動把 placeholder 覆寫成真 email。 + provisionEmail, emailIsFallback := resolveProvisionEmail(claims.Subject, claims.Email) + if emailIsFallback { + // log 標記哪些 user 是 fallback,方便盤點「待 MC 修好後覆寫」的數量。 + // 不 log email 內容(雖是 placeholder 無敏感性,仍保守只記 sub)。 + log.Warn("oidc.callback: email claim missing, using fallback placeholder", + "request_id", RequestIDFrom(c), + "action", "oidc.callback.email_fallback", + "user_id", claims.Subject, + ) + } if deps.UserStore != nil { if upErr := deps.UserStore.Upsert(ctx, &user.User{ ID: claims.Subject, // = users.id(D1-B) - Email: claims.Email, + Email: provisionEmail, Name: claims.Name, }); upErr != nil { // 不洩漏 raw error 給 user;log 留診斷(不含 token / secret)。 @@ -352,9 +440,19 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc { // oidcLogoutHandler 實作 POST /api/auth/logout(OIDC 模式)。 // -// 雛形不做 RP-initiated logout(不通知 IdP)— 只清本地 session + cookie。 +// 行為:一律清掉 visionA 自己的 session + cookie(原有行為,不變)。 // Idempotent:cookie 不存在或 session 已清也回 200。 // +// MC 連動登出(2026-06 新增):MC **不支援標準 OIDC RP-initiated logout** +// (discovery 列的 end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client +// 未註冊 post_logout_redirect_uri),所以後端無法乾淨地 302 導向 MC end_session。 +// 改採權宜方案:若 deps.OIDCLogoutURL 有設定,response 多回 idp_logout 欄位 +// (MC Web :7880 /account/logout 的 GET 入口),由前端 navigate 觸發 MC 登出, +// 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。 +// +// TODO:MC 補上標準 RP-initiated logout(GET end_session_endpoint + id_token_hint + +// post_logout_redirect_uri)後,改回後端 302 導向標準流程、移除這個 idp_logout 權宜欄位。 +// // 對齊 oidc-tdd.md §3.3。 func oidcLogoutHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { @@ -373,12 +471,22 @@ func oidcLogoutHandler(deps Deps) gin.HandlerFunc { "request_id", RequestIDFrom(c), "error", err) } + resp := LogoutResponse{Success: true} + // 只有設定了 MC logout URL 才回 idp_logout(可關設計、向下相容)。 + if deps.OIDCLogoutURL != "" { + resp.IDPLogout = &IDPLogoutInfo{ + URL: deps.OIDCLogoutURL, + Method: http.MethodGet, // MC Web(:7880) /account/logout GET 即 302 觸發登出(實測) + } + } + log.Info("oidc.logout", "request_id", RequestIDFrom(c), "action", "oidc.logout", "user_id", userID, + "idp_logout", deps.OIDCLogoutURL != "", // 不記 URL 本身(非機密但精簡);只記是否連動 ) - WriteSuccess(c, http.StatusOK, LogoutResponse{Success: true}) + WriteSuccess(c, http.StatusOK, resp) } } diff --git a/visionA-backend/internal/api/oidc_auth_test.go b/visionA-backend/internal/api/oidc_auth_test.go index f69f0b0..270feb7 100644 --- a/visionA-backend/internal/api/oidc_auth_test.go +++ b/visionA-backend/internal/api/oidc_auth_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/require" "visiona-backend/internal/oidc" + "visiona-backend/internal/user" "visiona-backend/internal/usersession" ) @@ -41,10 +42,10 @@ type mockOIDCProvider struct { verifyFn func(ctx context.Context, raw, expectedNonce string) (*oidc.Claims, error) // 記錄呼叫參數供 test assertion 用 - gotCode string - gotVerifier string - gotIDToken string - gotNonce string + gotCode string + gotVerifier string + gotIDToken string + gotNonce string } func (m *mockOIDCProvider) AuthorizationURL(state, nonce, codeChallenge string) string { @@ -410,6 +411,123 @@ func TestOIDCCallback_VerifyFails(t *testing.T) { assert.Contains(t, cbW.Body.String(), "id_token verification failed") } +// ---- TESTS: email fallback (放寬 email 必填) ----------------------------- + +// recordingUserStore 是攔截 Upsert 的測試 user.Store,記錄最後一次寫入的 User, +// 供 assertion 驗證 provision 進 DB 的 email(真 email vs fallback placeholder)。 +type recordingUserStore struct { + mu sync.Mutex + lastUpsert *user.User +} + +func (s *recordingUserStore) Upsert(ctx context.Context, in *user.User) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *in + s.lastUpsert = &cp + return nil +} + +func (s *recordingUserStore) Get(ctx context.Context, id string) (*user.User, error) { + return nil, user.ErrNotFound +} + +func (s *recordingUserStore) last() *user.User { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastUpsert +} + +// runCallbackWithProvider 跑完整 login + callback,回傳 callback 的 recorder。 +func runCallbackWithProvider(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder { + t.Helper() + loginW := httptest.NewRecorder() + r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)) + state := mustExtractStateFromLoginRedirect(t, loginW) + cookies := loginW.Result().Cookies() + + cbW := httptest.NewRecorder() + cbReq := httptest.NewRequest(http.MethodGet, + "/api/auth/callback?code=auth-code&state="+url.QueryEscape(state), nil) + for _, c := range cookies { + cbReq.AddCookie(c) + } + r.ServeHTTP(cbW, cbReq) + return cbW +} + +// TestOIDCCallback_EmailMissing_UsesFallback 驗證 email claim 為空時: +// - callback 仍 provision 成功(302、非 500) +// - 寫進 user store 的 email 是 "@noemail.visiona.local" placeholder(含 sub、unique) +func TestOIDCCallback_EmailMissing_UsesFallback(t *testing.T) { + provider := &mockOIDCProvider{ + verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) { + // 模擬 MC 現況:只有 sub / name,沒有 email + return &oidc.Claims{Subject: "sub-uuid-1", Email: "", Name: "Bob", Nonce: nonce}, nil + }, + } + store := &recordingUserStore{} + deps := newOIDCTestDeps(provider) + deps.UserStore = store + r := newOIDCRouter(deps) + + cbW := runCallbackWithProvider(t, r) + + require.Equal(t, http.StatusFound, cbW.Code, + "login should succeed even without email claim; body=%s", cbW.Body.String()) + + got := store.last() + require.NotNil(t, got, "Upsert should have been called") + assert.Equal(t, "sub-uuid-1", got.ID) + assert.Equal(t, "sub-uuid-1@noemail.visiona.local", got.Email, + "empty email claim must be provisioned with sub-based fallback placeholder") + assert.True(t, isFallbackEmail(got.Email), "provisioned email must be flagged as fallback") +} + +// TestOIDCCallback_EmailPresent_UsesRealEmail 驗證 email claim 非空時用真 email、不套 fallback。 +// (未來 MC 修好發 email 後的行為。) +func TestOIDCCallback_EmailPresent_UsesRealEmail(t *testing.T) { + provider := &mockOIDCProvider{ + verifyFn: func(ctx context.Context, raw, nonce string) (*oidc.Claims, error) { + return &oidc.Claims{Subject: "sub-uuid-2", Email: "real@example.com", Name: "Carol", Nonce: nonce}, nil + }, + } + store := &recordingUserStore{} + deps := newOIDCTestDeps(provider) + deps.UserStore = store + r := newOIDCRouter(deps) + + cbW := runCallbackWithProvider(t, r) + + require.Equal(t, http.StatusFound, cbW.Code, "body=%s", cbW.Body.String()) + + got := store.last() + require.NotNil(t, got) + assert.Equal(t, "real@example.com", got.Email, "non-empty email claim must be used as-is") + assert.False(t, isFallbackEmail(got.Email), "real email must not be flagged as fallback") +} + +// TestResolveProvisionEmail 驗證 fallback email 計算邏輯(格式正確、含 sub、unique-by-sub)。 +func TestResolveProvisionEmail(t *testing.T) { + t.Run("empty_email_uses_sub_fallback", func(t *testing.T) { + email, isFallback := resolveProvisionEmail("abc-123", "") + assert.Equal(t, "abc-123@noemail.visiona.local", email) + assert.True(t, isFallback) + assert.True(t, isFallbackEmail(email)) + }) + t.Run("real_email_passthrough", func(t *testing.T) { + email, isFallback := resolveProvisionEmail("abc-123", "x@y.com") + assert.Equal(t, "x@y.com", email) + assert.False(t, isFallback) + assert.False(t, isFallbackEmail(email)) + }) + t.Run("fallback_is_unique_per_sub", func(t *testing.T) { + e1, _ := resolveProvisionEmail("sub-A", "") + e2, _ := resolveProvisionEmail("sub-B", "") + assert.NotEqual(t, e1, e2, "different subs must yield different fallback emails (no unique-index clash)") + }) +} + // ---- TESTS: AuthMiddleware (OIDC 模式) + /api/auth/me + /api/auth/logout ---- // TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。 @@ -512,6 +630,84 @@ func TestOIDCLogout_ClearsSession(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, meW.Code) } +// TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset 驗證未設定 OIDCLogoutURL 時, +// logout response 不含 idp_logout 欄位(向下相容,只清本地 session)。 +func TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset(t *testing.T) { + provider := &mockOIDCProvider{} + deps := newOIDCTestDeps(provider) + // 不設 OIDCLogoutURL(預設空字串) + require.Empty(t, deps.OIDCLogoutURL) + r := newOIDCRouter(deps) + + cookies := loginAndCallback(t, r, deps, provider) + + logoutW := httptest.NewRecorder() + logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + for _, c := range cookies { + logoutReq.AddCookie(c) + } + r.ServeHTTP(logoutW, logoutReq) + require.Equal(t, http.StatusOK, logoutW.Code) + + // data.success == true 且不含 idp_logout + var env struct { + Success bool `json:"success"` + Data struct { + Success bool `json:"success"` + IDPLogout *IDPLogoutInfo `json:"idp_logout"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env)) + assert.True(t, env.Data.Success) + assert.Nil(t, env.Data.IDPLogout, "未設定 OIDCLogoutURL 時不應回 idp_logout") + // 原始 JSON 不應出現 idp_logout key(omitempty 驗證) + assert.NotContains(t, logoutW.Body.String(), "idp_logout") +} + +// TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet 驗證設定 OIDCLogoutURL 後, +// logout response 含 idp_logout(URL + Method=GET),且仍清本地 session。 +func TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet(t *testing.T) { + provider := &mockOIDCProvider{} + deps := newOIDCTestDeps(provider) + // MC Web :7880 logout 入口(不是 :7850 Api)。 + const mcLogoutURL = "https://stage-9527.innovedus.com:7880/account/logout" + deps.OIDCLogoutURL = mcLogoutURL + r := newOIDCRouter(deps) + + cookies := loginAndCallback(t, r, deps, provider) + + logoutW := httptest.NewRecorder() + logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + for _, c := range cookies { + logoutReq.AddCookie(c) + } + r.ServeHTTP(logoutW, logoutReq) + require.Equal(t, http.StatusOK, logoutW.Code) + + var env struct { + Data struct { + Success bool `json:"success"` + IDPLogout *IDPLogoutInfo `json:"idp_logout"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env)) + assert.True(t, env.Data.Success) + require.NotNil(t, env.Data.IDPLogout, "設定 OIDCLogoutURL 後應回 idp_logout") + assert.Equal(t, mcLogoutURL, env.Data.IDPLogout.URL) + assert.Equal(t, http.MethodGet, env.Data.IDPLogout.Method) + + // 本地 session 仍要被清掉(清 cookie) + var cleared *http.Cookie + for _, c := range logoutW.Result().Cookies() { + if c.Name == "visiona_session" { + cleared = c + break + } + } + require.NotNil(t, cleared, "expected visiona_session clearing cookie") + assert.True(t, cleared.MaxAge < 0, "expected MaxAge < 0 to clear cookie") +} + // TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。 func TestOIDC_LegacyLogin_Returns410(t *testing.T) { provider := &mockOIDCProvider{} diff --git a/visionA-backend/internal/config/config.go b/visionA-backend/internal/config/config.go index 2b8f407..0fb9d4a 100644 --- a/visionA-backend/internal/config/config.go +++ b/visionA-backend/internal/config/config.go @@ -118,6 +118,41 @@ type OIDCConfig struct { // 對齊 VISIONA_FRONTEND_URL(沿用 oidc-tdd.md §13.1 命名)。 PostLoginURL string + // LogoutURL 是「讓使用者連帶登出 IdP(Member Center)session」的入口 URL。 + // + // 背景(MC 限制,2026-06):MC 不支援標準 OIDC RP-initiated logout — + // discovery 雖列 end_session_endpoint=/auth/logout,但那是 POST+JSON 的 API + // (AuthController.Logout),非標準 GET + id_token_hint + post_logout_redirect_uri; + // 且 client 未註冊 post_logout_redirect_uri。因此後端無法乾淨地 302 導向 MC end_session。 + // + // MC 端唯一「瀏覽器可觸發」的登出是 MemberCenter.**Web**(:7880)的 AccountController.Logout + // (路由 /account/logout,_signInManager.SignOutAsync() 清 MC session)。實測 GET 即回 302, + // 不必 form POST。**注意 host 必須是 MC Web :7880,不是 MC Api :7850**——:7850 沒 logout 頁、會 404。 + // MC Web/Api 共享 DataProtection(SetApplicationName("MemberCenter")),清 :7880 session 後 + // :7850 authorize 也視為未登入、下次登入會問帳密,可換帳號。 + // + // 因此本欄位填 MC Web logout 入口(例: + // stage: https://stage-9527.innovedus.com:7880/account/logout)。 + // 後端 logout handler 會把這個 URL 連同 method=GET 回給前端,由前端 navigate 觸發 MC 登出 + // (見 oidc_auth.go oidcLogoutHandler)。 + // + // ⚠️ stage MC 跑 master 舊版(2026-04-30 image)、logout 不支援 returnUrl,清完會停在 MC 首頁、 + // 不自動回 visionA,前端需自行導回 visionA 登入。 + // + // **設計成可關**:留空時 logout response 不回 idp_logout 欄位、維持「只清本地 session」 + // 的舊行為(向下相容)。對齊 VISIONA_OIDC_LOGOUT_URL。 + // + // TODO(MC 補標準 RP-initiated logout 後):改回後端 302 導向標準 end_session_endpoint, + // 移除這個權宜欄位。 + LogoutURL string + + // PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login`: + // - true → 每次登入都讓 IdP 強制重新認證(忽略既有 SSO session)。 + // - false → 沿用 IdP 既有 session(標準 SSO),為預設值。 + // stage 想「每次都重新輸入帳密」時設 true;prod 視 UX 決定。 + // 對齊 VISIONA_OIDC_PROMPT_LOGIN(預設 false)。 + PromptLogin bool + // ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id, // 預留給未來 client_credentials grant flow(例如查詢使用者組織、推送通知等)。 // diff --git a/visionA-backend/internal/config/load.go b/visionA-backend/internal/config/load.go index b21dd0b..1c7e5d3 100644 --- a/visionA-backend/internal/config/load.go +++ b/visionA-backend/internal/config/load.go @@ -38,6 +38,11 @@ func Load() *Config { ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""), RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""), PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""), + // LogoutURL:MC Web :7880 logout 入口(GET /account/logout)。留空=logout 只清本地、 + // 不連動 MC(向下相容,見 OIDCConfig.LogoutURL 註解)。 + LogoutURL: getEnvString("VISIONA_OIDC_LOGOUT_URL", ""), + // prompt=login:true 時每次登入都強制 IdP 重新認證(忽略既有 SSO session)。 + PromptLogin: getEnvBool("VISIONA_OIDC_PROMPT_LOGIN", false), // A1:client_credentials grant 預留欄位,留空表「不啟用 service client」。 ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""), ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""), diff --git a/visionA-backend/internal/conversion/real_chain_e2e_test.go b/visionA-backend/internal/conversion/real_chain_e2e_test.go new file mode 100644 index 0000000..afbf09d --- /dev/null +++ b/visionA-backend/internal/conversion/real_chain_e2e_test.go @@ -0,0 +1,506 @@ +//go:build realconv + +// real_chain_e2e_test.go — 完整鏈路 e2e:真轉檔 → PromoteToModels → 真 model 進 PG 模型庫。 +// +// Owner: testing agent(realconv 完整鏈路 e2e — build tag 隔離,預設 CI 不跑) +// +// 與 real_converter_e2e_test.go 的差異: +// - real_converter_e2e_test.go:對「真轉檔服務」驗 visionA ConverterClient 的 **contract** +// (連線/認證/InitJob/GetJob/GetResult 解析、promote 失敗路徑)。當時 stage 是 stub。 +// - 本檔:stage 已切 **real 模式**(真 KTC、真 nef 800KB、promote OAuth 已修),驗 +// **整條 visionA 業務鏈路到底**: +// InitJob(真送 onnx+56 圖)→ poll completed(真轉檔) +// → flow.PromoteToModels(promote→converter MinIO pull NEF→storage.Put→建 model record) +// → **model 真的進了 PG 模型庫**(用 model.PostgresRepository 查、驗 owner/source_job_id/storage_key/faa_object_key) +// → 冪等(同 jobID 再 promote 回既有 model、不重複建) +// +// ⚠️ v0.6 架構事實(flow.go PromoteToModels):promote 與 download 都走 +// +// `converter.GetResult`(converter MinIO),visionA 端**不再直接打 FAA**。所以本鏈路 +// **不需要** wire FAA / MC client —— 只需要:真 ConverterClient(:9501 + API key) +// + 真 PG(建 model record)+ 一個 storage(streaming 寫 NEF;用本機 tmpdir LocalFS)。 +// 這是「能在本檔組出真 conversion service」的關鍵:依賴比想像中少。 +// +// 三個必要外部資源(缺任一 → t.Skip 並印啟用指令): +// 1. VISIONA_REAL_CONVERTER_URL + VISIONA_CONVERTER_API_KEY(與 real_converter_e2e_test.go 共用) +// 2. VISIONA_REAL_PG_DSN(已 migrate 的 PG;可指 stage 真 PG 或本機 docker PG) +// 3. onnx + ref images fixture(與 real_converter_e2e_test.go 共用 default 路徑 / env 覆寫) +// +// 本機通常連不到 stage(VPN/網路)+ 無 PG → 預設 Skip。由 Orchestrator 對 stage 跑 +// (見檔尾「給 Orchestrator 對 stage 跑」段)。 +package conversion + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/model" + "visiona-backend/internal/storage" +) + +// ========================================================================== +// 環境 guard(PG DSN 是本檔額外需要的;converter / fixture 沿用 requireRealConvEnv) +// ========================================================================== + +const ( + // realChainPGDSNEnv 是已 migrate 的 PostgreSQL DSN(如 + // postgres://visiona:pw@192.168.0.130:5432/visiona?sslmode=disable)。 + // 缺 → 本檔的完整鏈路測試 Skip(contract 測試在 real_converter_e2e_test.go 仍可獨立跑)。 + realChainPGDSNEnv = "VISIONA_REAL_PG_DSN" + + // 真 KTC 轉檔比 stub 慢得多(onnx→bie→nef 三 stage、bie 量化要跑 56 張 ref 圖)。 + // 給足 deadline;stub 經驗幾秒,real 端視機器可能數分鐘。 + realChainPollTimeout = 10 * time.Minute + realChainPollInterval = 3 * time.Second + + // 測試建立的 model name 前綴(可識別、cleanup 用)。 + realChainModelNamePrefix = "e2e-realchain-" +) + +// requireRealChainPG 解析 PG DSN、建 pool;缺 env → Skip。pool 在 t.Cleanup 關閉。 +func requireRealChainPG(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv(realChainPGDSNEnv) + if dsn == "" { + t.Skipf(`real-chain e2e 跳過:未設 %s(PG DSN)。 +本測試需要一個「已 migrate(含 users / models 表)」的 PostgreSQL: + - 指 stage 真 PG,或本機 docker PG(schema 來自 migrations/0001_create_users_models.up.sql) + 範例: + VISIONA_REAL_PG_DSN="postgres://visiona:@192.168.0.130:5432/visiona?sslmode=disable"`, realChainPGDSNEnv) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("real-chain e2e 跳過:PG pool 建立失敗(DSN 可達性問題?):%v", err) + } + // ping 確認連得上 + schema 存在(查 models 表) + pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer pingCancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + t.Skipf("real-chain e2e 跳過:PG ping 失敗:%v", err) + } + var reg int + if err := pool.QueryRow(pingCtx, `SELECT 1 FROM information_schema.tables + WHERE table_name = 'models'`).Scan(®); err != nil { + pool.Close() + t.Skipf("real-chain e2e 跳過:PG 缺 models 表(DSN 指到未 migrate 的 DB?):%v。"+ + "請先對該 DB 跑 migrations/0001_create_users_models.up.sql。", err) + } + + t.Cleanup(func() { pool.Close() }) + return pool +} + +// ensureTestUser upsert 一個合法 UUID user(滿足 models.owner_user_id FK)。 +// +// 回傳該 user 的 UUID 字串。固定 UUID(deterministic)讓重跑時 idempotent; +// email 帶可識別前綴避免撞真 user。 +func ensureTestUser(t *testing.T, pool *pgxpool.Pool) string { + t.Helper() + // 固定 namespace UUID(v5 不需要;這裡直接寫死一個明顯是測試用的 UUID)。 + const testUserID = "e2e0c0de-0000-4000-8000-000000000001" + const testEmail = "e2e-realchain@example.invalid" + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // upsert by id;email 用 functional unique index(lower(email)),ON CONFLICT (id) 即可。 + _, err := pool.Exec(ctx, ` + INSERT INTO users (id, email, name, roles) + VALUES ($1, $2, 'e2e realchain test user', '{}') + ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, updated_at = now()`, + testUserID, testEmail) + if err != nil { + t.Fatalf("ensure test user(upsert users)失敗:%v\n"+ + "(若是 email unique 衝突,代表有殘留同 email 的別的 user — 換 testEmail 或先清)", err) + } + return testUserID +} + +// ========================================================================== +// in-test adapters:把 model.PostgresRepository / storage.LocalFSStore 包成 +// conversion.ModelStore / conversion.Storage(對映 cmd/api-server/conversion_adapters.go, +// 但 main package 的 adapter 不可 import,這裡在 conversion package 內等價重寫)。 +// ========================================================================== + +// pgModelStore 把 model.Repository 包成 conversion.ModelStore(含 ModelRecord ↔ model.Model 轉換)。 +type pgModelStore struct { + repo model.Repository +} + +func (s *pgModelStore) Save(ctx context.Context, rec *ModelRecord) error { + if rec == nil { + return errors.New("pgModelStore.Save requires non-nil record") + } + now := time.Now().UTC() + uploadedAt := now + if !rec.UpdatedAt.IsZero() { + uploadedAt = rec.UpdatedAt + } + m := &model.Model{ + ID: rec.ID, + OwnerUserID: rec.OwnerUserID, + Name: rec.Name, + Description: rec.Description, + StorageKey: rec.StorageKey, + FileSize: rec.FileSize, + FileChecksum: rec.FileChecksum, + TargetChip: rec.TargetChip, + InputShape: rec.InputShape, + Classes: rec.Classes, + Framework: rec.Framework, + Source: rec.Source, + SourceJobID: rec.SourceJobID, + FAAObjectKey: rec.FAAObjectKey, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + UploadedAt: &uploadedAt, + } + return s.repo.Save(ctx, m) +} + +func (s *pgModelStore) FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*ModelRecord, error) { + if ownerUserID == "" || sourceJobID == "" { + return nil, nil + } + models, err := s.repo.List(ctx, model.ListFilter{ + OwnerUserID: ownerUserID, + Source: model.SourceConverted, + }) + if err != nil { + return nil, fmt.Errorf("pgModelStore.FindBySourceJobID list: %w", err) + } + for _, m := range models { + if m.SourceJobID == sourceJobID { + return &ModelRecord{ + ID: m.ID, + OwnerUserID: m.OwnerUserID, + Name: m.Name, + Description: m.Description, + StorageKey: m.StorageKey, + FileSize: m.FileSize, + FileChecksum: m.FileChecksum, + TargetChip: m.TargetChip, + InputShape: m.InputShape, + Classes: m.Classes, + Framework: m.Framework, + Source: m.Source, + SourceJobID: m.SourceJobID, + FAAObjectKey: m.FAAObjectKey, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }, nil + } + } + return nil, nil +} + +func (s *pgModelStore) GenerateID() string { return uuid.NewString() } + +// localStorage 把 storage.Store 包成 conversion.Storage(只需 Put)。 +type localStorage struct { + store storage.Store +} + +func (s *localStorage) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error { + return s.store.Put(ctx, key, r, size, meta) +} + +// buildRealChainService 組一個「真 converter + 真 PG + 本機 LocalFS storage」的 conversion.Service。 +// +// 回傳 service + 底層 pgRepo(測試直接用 repo 查 PG 驗證 model 落盤)。 +func buildRealChainService(t *testing.T, env realConvEnv, pool *pgxpool.Pool) (Service, *model.PostgresRepository) { + t.Helper() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + + converterClient := NewConverterClient(ConverterClientOpts{ + BaseURL: env.baseURL, + APIKey: env.apiKey, + Logger: logger, + }) + ownership := NewOwnership(converterClient, logger) + + pgRepo := model.NewPostgresRepository(pool) + modelStore := &pgModelStore{repo: pgRepo} + + // 本機 tmpdir LocalFS storage(NEF streaming 寫進去;測完隨 t.TempDir 清)。 + fsStore, err := storage.NewLocalFSStore(t.TempDir(), "http://localhost/files", "e2e-realchain-signing") + if err != nil { + t.Fatalf("建 LocalFSStore 失敗:%v", err) + } + storageAdapter := &localStorage{store: fsStore} + + svc, err := NewService(FlowOpts{ + Converter: converterClient, + Ownership: ownership, + ModelStore: modelStore, + Storage: storageAdapter, + Logger: logger, + }) + if err != nil { + t.Fatalf("NewService 失敗:%v", err) + } + return svc, pgRepo +} + +// ========================================================================== +// E2E:完整鏈路 — 真轉檔 → PromoteToModels → 真 model 進 PG → 冪等 +// ========================================================================== + +// TestRealChain_ConvertPromoteToPGModelLibrary 驗整條 visionA 業務鏈路到底。 +// +// [1] flow.InitJob 真送 onnx + 56 圖(flow 內部重組 multipart、注入 user_id、寫 ownership) +// [2] flow.GetJob poll 到 completed(真 KTC 轉檔;給足 10 分鐘) +// [3] flow.PromoteToModels:promote(真 nef 推上 FAA + 保留 converter MinIO) +// → converter.GetResult 拉 NEF stream → storage.Put → model.PostgresRepository.Save +// [4] 驗 model 真的進 PG:用 pgRepo.Get(model_id) + List by owner,確認 +// owner / source_job_id / storage_key / file_size / source=converted / faa_object_key 有值 +// [5] 冪等:同 jobID 再 PromoteToModels → 回**同一個** model_id(不重複建) +func TestRealChain_ConvertPromoteToPGModelLibrary(t *testing.T) { + env := requireRealConvEnv(t) + pool := requireRealChainPG(t) + userID := ensureTestUser(t, pool) + svc, pgRepo := buildRealChainService(t, env, pool) + + ctx := context.Background() + + // ── [1] flow.InitJob(真送)───────────────────────────────────────────── + body, contentType, refCount := buildRealInitBody(t, env) + t.Logf("[1] InitJob:multipart %d bytes,ref_images=%d 張,user=%s", len(body), refCount, userID) + + // 注意:flow.InitJob 內部會重組 multipart 並注入 user_id(黑名單 client 帶來的 user_id)。 + // buildRealInitBody 已寫了 user_id=realConvTestUserID,但會被 flow 用本測 userID 蓋掉, + // 這正是要驗的安全行為(§4.2)。傳進去的 ContentType 必須含 boundary。 + initCtx, initCancel := context.WithTimeout(ctx, 90*time.Second) + job, err := svc.InitJob(initCtx, InitJobInput{ + UserID: userID, + ContentType: contentType, + Body: bytes.NewReader(body), + ContentLength: int64(len(body)), + }) + initCancel() + if err != nil { + if errors.Is(err, ErrConverterAuthFailed) { + t.Fatalf("[1] InitJob 認證失敗(API key 未對齊?):%v", err) + } + t.Fatalf("[1] InitJob 失敗:%v", err) + } + if job.JobID == "" { + t.Fatalf("[1] InitJob 回的 job_id 為空:%+v", job) + } + jobID := job.JobID + t.Logf("[1] InitJob OK:job_id=%s status=%s stage=%s", jobID, job.Status, job.Stage) + + // ── [2] flow.GetJob poll 到 completed(真轉檔,給足 10 分鐘)────────────── + final := pollChainUntilTerminal(t, svc, userID, jobID) + t.Logf("[2] 終態:status=%s stage=%q source_filename=%q target_chip=%q error_code=%q", + final.Status, final.Stage, final.SourceFilename, final.TargetChip, final.ErrorCode) + if final.Status != "completed" { + t.Fatalf("[2] 真轉檔未 completed(status=%s error_code=%q msg=%q)。"+ + "確認 stage worker 已切 real 模式、且該 fixture 能轉成功。", + final.Status, final.ErrorCode, final.ErrorMessage) + } + + // ── [3] flow.PromoteToModels(真 promote → MinIO pull → storage → 建 model record)── + modelName := realChainModelNamePrefix + jobID[:8] + promoteCtx, promoteCancel := context.WithTimeout(ctx, 2*time.Minute) + promoteRes, err := svc.PromoteToModels(promoteCtx, userID, jobID, modelName) + promoteCancel() + if err != nil { + t.Fatalf("[3] PromoteToModels 失敗:%v\n"+ + "(promote OAuth 已修的前提下不該失敗;若回 ErrConverterUnavailable 代表 promote→FAA 仍有問題,"+ + "回報 Orchestrator,勿自行改 production code。)", err) + } + if promoteRes == nil || promoteRes.ModelID == "" { + t.Fatalf("[3] PromoteToModels 回的 model_id 為空:%+v", promoteRes) + } + modelID := promoteRes.ModelID + // cleanup:測完軟刪除 model(pgRepo.Delete 寫 deleted_at;不留垃圾在模型庫)。 + t.Cleanup(func() { + dctx, dcancel := context.WithTimeout(context.Background(), 10*time.Second) + defer dcancel() + if derr := pgRepo.Delete(dctx, modelID); derr != nil { + t.Logf("cleanup:軟刪除 model %s 失敗(殘留在 PG,需人工清):%v", modelID, derr) + } else { + t.Logf("cleanup:已軟刪除 model %s", modelID) + } + }) + t.Logf("[3] PromoteToModels OK:model_id=%s name=%q source=%s source_job_id=%s file_size=%d status=%s", + modelID, promoteRes.Name, promoteRes.Source, promoteRes.SourceJobID, promoteRes.FileSize, promoteRes.Status) + + // 基本一致性 + if promoteRes.Source != "converted" { + t.Errorf("[3] promote source 預期 converted,得 %q", promoteRes.Source) + } + if promoteRes.SourceJobID != jobID { + t.Errorf("[3] promote source_job_id 預期 %s,得 %s", jobID, promoteRes.SourceJobID) + } + if promoteRes.FileSize <= 0 { + t.Errorf("[3] promote file_size 應 > 0(真 nef ~800KB),得 %d", promoteRes.FileSize) + } + + // ── [4] 驗 model 真的進 PG(直接查 PostgresRepository)──────────────────── + getCtx, getCancel := context.WithTimeout(ctx, 10*time.Second) + got, err := pgRepo.Get(getCtx, modelID) + getCancel() + if err != nil { + t.Fatalf("[4] 從 PG 查不到 model %s(model 未真正落盤?):%v", modelID, err) + } + t.Logf("[4] PG model 落盤:id=%s owner=%s name=%q storage_key=%q faa_object_key=%q "+ + "file_size=%d source=%s source_job_id=%s target_chip=%q", + got.ID, got.OwnerUserID, got.Name, got.StorageKey, got.FAAObjectKey, + got.FileSize, got.Source, got.SourceJobID, got.TargetChip) + + if got.OwnerUserID != userID { + t.Errorf("[4] PG model owner 預期 %s,得 %s", userID, got.OwnerUserID) + } + if got.SourceJobID != jobID { + t.Errorf("[4] PG model source_job_id 預期 %s,得 %s", jobID, got.SourceJobID) + } + if got.Source != model.SourceConverted { + t.Errorf("[4] PG model source 預期 converted,得 %q", got.Source) + } + if got.StorageKey == "" { + t.Errorf("[4] PG model storage_key 不該為空(promote 應寫 visionA storage key)") + } + if got.FileSize <= 0 { + t.Errorf("[4] PG model file_size 應 > 0,得 %d", got.FileSize) + } + // FAAObjectKey:v0.6 promote 仍寫此欄位(= converter promote 的 target_object_key)。 + // 真 promote 成功路徑下應有值;若為空記下供判讀(不一定 fail — 視 converter promote response)。 + if got.FAAObjectKey == "" { + t.Logf("[4] 注意:PG model faa_object_key 為空。v0.6 promote 應回 target_object_key;" + + "若 converter promote response 未帶 target_object_key 則 fallback 為 visionA 端組的 key," + + "理論上不該空。確認 converter promote 回傳格式。") + } + + // List by owner 也應看得到(驗 List 路徑 + filter 正確) + listCtx, listCancel := context.WithTimeout(ctx, 10*time.Second) + models, err := pgRepo.List(listCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted}) + listCancel() + if err != nil { + t.Fatalf("[4] List by owner 失敗:%v", err) + } + if !containsModelID(models, modelID) { + t.Errorf("[4] List by owner=%s source=converted 結果未含 model %s(共 %d 筆)", + userID, modelID, len(models)) + } + + // ── [5] 冪等:同 jobID 再 PromoteToModels → 回同一個 model_id ────────────── + idemCtx, idemCancel := context.WithTimeout(ctx, 1*time.Minute) + promoteRes2, err := svc.PromoteToModels(idemCtx, userID, jobID, modelName) + idemCancel() + if err != nil { + t.Fatalf("[5] 第二次 PromoteToModels(冪等)失敗:%v", err) + } + if promoteRes2 == nil || promoteRes2.ModelID != modelID { + t.Errorf("[5] 冪等失敗:第二次 promote 應回同一 model_id=%s,得 %+v", modelID, promoteRes2) + } else { + t.Logf("[5] 冪等 OK:同 jobID 再 promote 回既有 model_id=%s(未重複建)", modelID) + } + + // 再查一次 PG 確認只有一筆 converted model 對應此 jobID(冪等不該建第二筆) + cntCtx, cntCancel := context.WithTimeout(ctx, 10*time.Second) + all, err := pgRepo.List(cntCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted}) + cntCancel() + if err != nil { + t.Fatalf("[5] 冪等後 List 失敗:%v", err) + } + n := 0 + for _, m := range all { + if m.SourceJobID == jobID { + n++ + } + } + if n != 1 { + t.Errorf("[5] 冪等後對應 jobID=%s 的 converted model 應只有 1 筆,得 %d 筆", jobID, n) + } + + t.Logf("完整鏈路驗證通過:真轉檔 → PromoteToModels → model 進 PG → 冪等,全部 OK。") +} + +// pollChainUntilTerminal 用 flow.GetJob 對真服務 poll 到 completed/failed 或 timeout。 +// +// 與 real_converter_e2e_test.go 的 pollUntilTerminal 不同:這裡走 **flow.GetJob**(含 ownership +// 檢查),驗的是 visionA 業務層的 poll,而非 raw client。回傳對外 *Job。 +func pollChainUntilTerminal(t *testing.T, svc Service, userID, jobID string) *Job { + t.Helper() + deadline := time.Now().Add(realChainPollTimeout) + var last *Job + logEvery := 10 // 每 N 次 poll 印一次進度,避免長轉檔時 log 太吵 + i := 0 + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + j, err := svc.GetJob(ctx, userID, jobID) + cancel() + if err != nil { + t.Logf("GetJob 暫時失敗(續 poll):%v", err) + time.Sleep(realChainPollInterval) + continue + } + last = j + i++ + if i%logEvery == 0 { + t.Logf(" poll #%d:status=%s stage=%q progress=%d stage_progress=%d", + i, j.Status, j.Stage, j.Progress, j.StageProgress) + } + switch j.Status { + case "completed", "failed": + return j + default: + time.Sleep(realChainPollInterval) + } + } + if last == nil { + t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID) + } + t.Logf("poll 超時(%s),回最後一次狀態:status=%s", realChainPollTimeout, last.Status) + return last +} + +// containsModelID 檢查 model 清單是否含指定 id。 +func containsModelID(models []*model.Model, id string) bool { + for _, m := range models { + if m.ID == id { + return true + } + } + return false +} + +// ========================================================================== +// 給 Orchestrator 對 stage 跑(本機連不到 stage / 無 PG → 預設 Skip) +// +// 前置: +// 1. 取 converter API key(從 stage container env,不 hardcode): +// KEY=$(docker -H tcp://192.168.0.130:2375 exec \ +// kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY) +// 2. 取 PG DSN:指 stage 真 PG(已 migrate,含 users / models 表)。 +// 若 stage PG 連線資訊未知,可在 stage 機器上跑、用 localhost DSN; +// 或本機起一個 docker PG 並對它跑 migrations/0001_create_users_models.up.sql。 +// 3. fixture:onnx + 56 張 ref 圖(default 路徑見 real_converter_e2e_test.go; +// env VISIONA_REAL_CONVERTER_ONNX / VISIONA_REAL_CONVERTER_IMAGES 可覆寫)。 +// +// 跑: +// VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \ +// VISIONA_CONVERTER_API_KEY="$KEY" \ +// VISIONA_REAL_PG_DSN="postgres://visiona:@:5432/visiona?sslmode=disable" \ +// go test -tags=realconv ./internal/conversion/ \ +// -run TestRealChain_ConvertPromoteToPGModelLibrary -count=1 -v -timeout=20m +// +// 注意:真 KTC 轉檔較慢,-timeout 給 20m(poll deadline 內建 10m)。 +// 測完會軟刪除建立的 model(t.Cleanup);test user 留在 PG(無害、固定 UUID 可重用)。 +// ========================================================================== diff --git a/visionA-backend/internal/conversion/real_converter_e2e_test.go b/visionA-backend/internal/conversion/real_converter_e2e_test.go new file mode 100644 index 0000000..97bb063 --- /dev/null +++ b/visionA-backend/internal/conversion/real_converter_e2e_test.go @@ -0,0 +1,491 @@ +//go:build realconv + +// real_converter_e2e_test.go — 對「真實轉檔服務」(kneron_model_converter task-scheduler) +// 驗 visionA conversion client 的 contract e2e(**非 mock**)。 +// +// Owner: testing agent(真轉檔 contract e2e — build tag 隔離,預設 CI 不跑) +// +// build tag `realconv`:只在 `go test -tags=realconv` 時編譯/執行。 +// - 預設 `go test ./...` 與 `-tags=dbtest` 都**不**編譯本檔(避免外部服務依賴污染主測試集)。 +// - 本機通常連不到 stage(VPN/網路),由 Orchestrator 對 stage 192.168.0.130 跑(見檔尾「給 +// Orchestrator 對 stage 跑」段)。 +// +// 測什麼(mock 測不到的部分):直接用 visionA 的 ConverterClient(converter_client.go,非 +// httptest mock)打 stage 真轉檔服務,驗 visionA 端對「真服務實際回的格式」解析正確: +// 1. 連線 + 認證(真 API key 過認證) +// 2. InitJob 真送 onnx + 56 張 ref 圖(multipart)→ 拿 job_id / status=created / stage=onnx +// 3. GetJob poll 到 completed(stub 模式幾秒)→ 解析真服務 completed response 不報錯/不 panic +// 4. stub 環境真實行為:result_object_keys=null、analysis_info 不存在時,visionA +// InputShape/Classes/Framework 為零值(B4 鏈路防禦性,不報錯) +// 5. promote 失敗路徑:stub 無真 nef → converter 回 500 → visionA 正確包裝成 sentinel、不 panic +// +// ⚠️ 範圍界定:**對 stub 模式驗 contract**。stage 三個 worker(onnx/bie/nef)皆 WORKER_MODE=stub +// (Dockerfile.stub),不跑真 KTC、產佔位輸出(GET /result 回 15 bytes "STUB_NEF_OUTPUT")。 +// 真 KTC 轉檔 / 真 nef / promote 成功路徑需轉檔端 worker 切真模式,**不在此測範圍** +// (那是 kneron_model_converter repo 的事,本測不嘗試開真 KTC / 改 worker 模式)。 +// +// 對齊:converter_client.go endpoint 註解 + Orchestrator 手動實測的真服務 contract。 +package conversion + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "mime/multipart" + "os" + "path/filepath" + "sort" + "strconv" + "testing" + "time" +) + +// ========================================================================== +// 環境 guard — 沒設 env / fixture 不存在 → t.Skip(優雅跳過,印清楚啟用方式) +// ========================================================================== + +const ( + // realConvURLEnv 是 stage 真轉檔服務 base URL(如 http://192.168.0.130:9501)。 + realConvURLEnv = "VISIONA_REAL_CONVERTER_URL" + // realConvAPIKeyEnv 是 stage 上對齊的 converter API key(從 container env 取,**不 hardcode**)。 + realConvAPIKeyEnv = "VISIONA_CONVERTER_API_KEY" + // realConvOnnxEnv 是 input onnx fixture 路徑(覆寫預設)。 + realConvOnnxEnv = "VISIONA_REAL_CONVERTER_ONNX" + // realConvImagesEnv 是 ref images 目錄路徑(覆寫預設)。 + realConvImagesEnv = "VISIONA_REAL_CONVERTER_IMAGES" + + // 預設 fixture 路徑(Orchestrator 手動實測來源)。env 沒設時試這些,仍找不到 → Skip。 + defaultOnnxFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie/input.onnx" + defaultImagesFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie_images" + + // 測試用可識別 user_id(stub job 無害、會自然過期 expires_at)。 + realConvTestUserID = "e2e-realconv-test" + // stub 模式下 onnx→bie→nef 幾秒就跑完;給寬裕 poll deadline。 + realConvPollTimeout = 90 * time.Second + realConvPollInterval = 1 * time.Second +) + +// realConvEnv 是 guard 通過後回傳的環境設定。 +type realConvEnv struct { + baseURL string + apiKey string + onnxPath string + imagesPath string +} + +// requireRealConvEnv 檢查 env + fixture;缺任一 → t.Skip 並印「怎麼啟用這個測試」。 +func requireRealConvEnv(t *testing.T) realConvEnv { + t.Helper() + + baseURL := os.Getenv(realConvURLEnv) + apiKey := os.Getenv(realConvAPIKeyEnv) + + if baseURL == "" || apiKey == "" { + t.Skipf(`real-converter e2e 跳過:未設環境變數。 +啟用方式(對 stage 192.168.0.130 跑): + 1. 取 stage 上對齊的 API key(從 converter container env): + KEY=$(docker -H tcp://192.168.0.130:2375 exec kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY) + 2. 跑: + VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \ + VISIONA_CONVERTER_API_KEY="$KEY" \ + go test -tags=realconv ./internal/conversion/ -run TestRealConverter -count=1 -v +缺少的環境變數:%s / %s(兩者皆必填)`, realConvURLEnv, realConvAPIKeyEnv) + } + + onnxPath := os.Getenv(realConvOnnxEnv) + if onnxPath == "" { + onnxPath = defaultOnnxFixture + } + imagesPath := os.Getenv(realConvImagesEnv) + if imagesPath == "" { + imagesPath = defaultImagesFixture + } + + if _, err := os.Stat(onnxPath); err != nil { + t.Skipf("real-converter e2e 跳過:input onnx fixture 不存在:%s(err=%v)。"+ + "用 %s 指定路徑。", onnxPath, err, realConvOnnxEnv) + } + if fi, err := os.Stat(imagesPath); err != nil || !fi.IsDir() { + t.Skipf("real-converter e2e 跳過:ref images 目錄不存在:%s(err=%v)。"+ + "用 %s 指定路徑。", imagesPath, err, realConvImagesEnv) + } + + return realConvEnv{ + baseURL: baseURL, + apiKey: apiKey, + onnxPath: onnxPath, + imagesPath: imagesPath, + } +} + +// newRealConverterClient 用真 env 建一個 ConverterClient(**非 mock**,打真服務)。 +// +// 用較長的 init/get timeout:真服務 multipart 上傳 + stub 幾秒處理需要餘裕。 +func newRealConverterClient(t *testing.T, env realConvEnv) ConverterClient { + t.Helper() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + return NewConverterClient(ConverterClientOpts{ + BaseURL: env.baseURL, + APIKey: env.apiKey, + Logger: logger, + }) +} + +// ========================================================================== +// fixture:組真 multipart body(onnx + 56 張 ref 圖 + form fields) +// ========================================================================== + +// buildRealInitBody 組 stage 真轉檔服務 POST /api/v1/jobs 需要的 multipart body。 +// +// 對齊 Orchestrator 手動實測的真 API contract: +// fields: model(file onnx) + ref_images(N files) + user_id + model_id + version + platform("520") + enable_evaluate("false") +// +// 回傳 body bytes + Content-Type(含 boundary)+ ref_images 數量(給斷言驗 ref_images_count)。 +func buildRealInitBody(t *testing.T, env realConvEnv) (body []byte, contentType string, refCount int) { + t.Helper() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + // form fields(converter multer 慣例:fields 在 file 之前) + writeField := func(name, val string) { + if err := mw.WriteField(name, val); err != nil { + t.Fatalf("write multipart field %q: %v", name, err) + } + } + writeField("user_id", realConvTestUserID) + // model_id 必須能 parse 成數字(轉檔服務驗證:非數字字串 → 400 validation_error)。 + // 對齊手動實測(model_id=9999)與轉檔端 test_flow_e2e.py(model_id=10)。 + writeField("model_id", "9999") + // version 為字串、無型別限制;用簡單值對齊手動成功("v1")。 + writeField("version", "v1") + writeField("platform", "520") + writeField("enable_evaluate", "false") + + // model file(onnx) + onnxBytes, err := os.ReadFile(env.onnxPath) + if err != nil { + t.Fatalf("read onnx fixture %s: %v", env.onnxPath, err) + } + fw, err := mw.CreateFormFile("model", filepath.Base(env.onnxPath)) + if err != nil { + t.Fatalf("create form file model: %v", err) + } + if _, err := fw.Write(onnxBytes); err != nil { + t.Fatalf("write onnx bytes: %v", err) + } + + // ref_images:讀目錄下所有 *.jpg,依檔名數字排序送(與手動實測一致) + entries, err := os.ReadDir(env.imagesPath) + if err != nil { + t.Fatalf("read images dir %s: %v", env.imagesPath, err) + } + jpgs := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if filepath.Ext(name) == ".jpg" { + jpgs = append(jpgs, name) + } + } + // 依檔名數字排序(0.jpg, 1.jpg, ... 而非字典序 1,10,100) + sort.Slice(jpgs, func(i, j int) bool { + ni := jpgStem(jpgs[i]) + nj := jpgStem(jpgs[j]) + if ni != nj { + return ni < nj + } + return jpgs[i] < jpgs[j] + }) + for _, name := range jpgs { + full := filepath.Join(env.imagesPath, name) + imgBytes, err := os.ReadFile(full) + if err != nil { + t.Fatalf("read ref image %s: %v", full, err) + } + ifw, err := mw.CreateFormFile("ref_images", name) + if err != nil { + t.Fatalf("create form file ref_images %s: %v", name, err) + } + if _, err := ifw.Write(imgBytes); err != nil { + t.Fatalf("write ref image %s: %v", name, err) + } + refCount++ + } + + if err := mw.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + if refCount == 0 { + t.Fatalf("ref images 目錄 %s 沒有 .jpg 檔(預期 56 張)", env.imagesPath) + } + return buf.Bytes(), mw.FormDataContentType(), refCount +} + +// jpgStem 取 ".jpg" 的數字部分;非數字回 -1(排到最前,不影響正確性)。 +func jpgStem(name string) int { + stem := name[:len(name)-len(filepath.Ext(name))] + n, err := strconv.Atoi(stem) + if err != nil { + return -1 + } + return n +} + +// ========================================================================== +// E2E #1:連線 + 認證 + InitJob + GetJob poll(contract 主路徑) +// ========================================================================== + +// TestRealConverter_InitAndPollContract 驗 visionA ConverterClient 對真 stage 服務的主路徑: +// +// InitJob(真送 onnx + 56 圖)→ status=created/running + stage=onnx +// → GetJob poll 到 completed(stub 幾秒) +// → visionA 正確解析真服務 completed response(即使 result_object_keys=null、無 analysis_info +// 也不報錯、不 panic;InputShape/Classes/Framework 為零值) +// +// 連線 + 認證隱含驗證:InitJob 沒回 ErrConverterAuthFailed = API key 過認證、連得上。 +func TestRealConverter_InitAndPollContract(t *testing.T) { + env := requireRealConvEnv(t) + client := newRealConverterClient(t, env) + + body, contentType, refCount := buildRealInitBody(t, env) + t.Logf("組好 multipart body:%d bytes,ref_images=%d 張", len(body), refCount) + + // ── InitJob(真送)────────────────────────────────────────────────── + initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer initCancel() + + cj, err := client.InitJob(initCtx, InitConverterJobReq{ + UserID: realConvTestUserID, + Platform: "520", + SourceFilename: filepath.Base(env.onnxPath), + Body: bytes.NewReader(body), + BodyContentType: contentType, + }) + if err != nil { + // 認證失敗 → 明確訊息(最可能:API key 未對齊) + if errors.Is(err, ErrConverterAuthFailed) { + t.Fatalf("InitJob 認證失敗(API key 未對齊?):%v\n"+ + "確認 %s 與 stage container CONVERTER_API_KEY 一致。", err, realConvAPIKeyEnv) + } + t.Fatalf("InitJob 失敗:%v", err) + } + + // 連線 + 認證 OK(沒回 auth error 就是過了) + if cj.JobID == "" { + t.Fatalf("InitJob 回的 job_id 為空:%+v", cj) + } + // 真服務回 status=created(手動實測);visionA client 透傳。亦容忍 running(race)。 + if cj.Status != "created" && cj.Status != "running" { + t.Errorf("InitJob status 預期 created/running,得 %q(job=%+v)", cj.Status, cj) + } + if cj.Stage != "onnx" { + t.Errorf("InitJob stage 預期 onnx,得 %q", cj.Stage) + } + t.Logf("InitJob OK:job_id=%s status=%s stage=%s", cj.JobID, cj.Status, cj.Stage) + + // ── GetJob poll 到 completed ─────────────────────────────────────── + final := pollUntilTerminal(t, client, cj.JobID) + + t.Logf("終態 job:status=%s stage=%q progress=%v input_filename=%q platform=%q", + final.Status, final.Stage, derefInt(final.Progress), final.SourceFilename, final.Platform) + + // stub 模式預期 completed;若 failed 也不該 panic(contract 仍須能解析) + if final.Status != "completed" { + t.Errorf("poll 終態預期 completed(stub 模式幾秒完成),得 %q(error_code=%q msg=%q)", + final.Status, final.ErrorCode, final.ErrorMessage) + } + // completed 時真服務回 stage=null → visionA 解析成 ""(不報錯) + if final.Status == "completed" && final.Stage != "" { + t.Errorf("completed 時 stage 預期空字串(真服務回 null),得 %q", final.Stage) + } + + // ── 驗 stub 環境真實行為:analysis_info 缺 → B4 metadata 全零值,不報錯 ── + // 這是 mock 測不到的:真 stub 服務的 completed response 沒有 analysis_info, + // visionA toConverterJob 必須優雅留零值(防禦性),不能 panic / 報錯。 + if final.InputShape != nil { + t.Logf("注意:真服務回了 analysis_info.input_shape=%v(stub 模式預期 nil;"+ + "若轉檔端已串 analysis_info 則此為正常)", final.InputShape) + } + if len(final.Classes) != 0 { + t.Logf("注意:真服務回了 classes=%v(stub 模式預期空)", final.Classes) + } + // 不對 InputShape 斷言「必為 nil」——若轉檔端未來串好 analysis_info,這裡不該 fail; + // 重點是「解析不 panic」,能跑到這行就證明解析成功。 + t.Logf("contract 驗證通過:visionA client 成功解析真服務 completed response,未 panic") +} + +// pollUntilTerminal 用 visionA GetJob 對真服務 poll 到 completed/failed 或 timeout。 +func pollUntilTerminal(t *testing.T, client ConverterClient, jobID string) *ConverterJob { + t.Helper() + deadline := time.Now().Add(realConvPollTimeout) + var last *ConverterJob + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + cj, err := client.GetJob(ctx, jobID) + cancel() + if err != nil { + // 真服務暫時 5xx → GetJob 內已 retry;這裡再容忍一次(記 log 續 poll) + t.Logf("GetJob 暫時失敗(續 poll):%v", err) + time.Sleep(realConvPollInterval) + continue + } + last = cj + switch cj.Status { + case "completed", "failed": + return cj + default: + time.Sleep(realConvPollInterval) + } + } + if last == nil { + t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID) + } + t.Logf("poll 超時,回最後一次狀態:status=%s", last.Status) + return last +} + +// ========================================================================== +// E2E #2:promote 失敗路徑(stub 無真 nef → converter 500 → visionA 不崩) +// ========================================================================== + +// TestRealConverter_PromoteFailsGracefullyOnStub 驗「真服務失敗時 visionA 不崩」: +// +// stub 模式下 GET /result 回 15 bytes 佔位字串、promote 因無真 nef → converter 回 500 +// `{error:{code:"internal_error",...}}`。visionA Promote 必須: +// - 不 panic +// - 把 500 正確包裝成 ErrConverterUnavailable(mapPromoteError 預設 5xx 分支) +// +// 這是有價值的 contract 測試:驗 visionA 對真服務 5xx 的錯誤處理鏈正確。 +func TestRealConverter_PromoteFailsGracefullyOnStub(t *testing.T) { + env := requireRealConvEnv(t) + client := newRealConverterClient(t, env) + + // 先 init + poll 到 completed(promote 前提:job 須 completed) + body, contentType, _ := buildRealInitBody(t, env) + initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer initCancel() + cj, err := client.InitJob(initCtx, InitConverterJobReq{ + UserID: realConvTestUserID, + Platform: "520", + SourceFilename: filepath.Base(env.onnxPath), + Body: bytes.NewReader(body), + BodyContentType: contentType, + }) + if err != nil { + t.Fatalf("InitJob 失敗(promote 測試前置):%v", err) + } + final := pollUntilTerminal(t, client, cj.JobID) + if final.Status != "completed" { + t.Skipf("job 未 completed(status=%s),跳過 promote 失敗路徑驗證", final.Status) + } + + // ── Promote — stub 無真 nef,預期 converter 回 500 ──────────────────── + promoteCtx, promoteCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer promoteCancel() + + // 包進 func 確保「不 panic」可被測到(panic 會讓 test fail 並印 stack) + res, perr := client.Promote(promoteCtx, cj.JobID, PromoteReq{ + UserID: realConvTestUserID, + Source: "nef", + TargetObjectKey: "models/" + realConvTestUserID + "/" + cj.JobID + ".nef", + }) + + if perr == nil { + // 若 stage 某天切真模式 → promote 可能成功;不 fail(記 log,contract 仍成立) + t.Logf("注意:Promote 成功(stage 可能已非 stub 模式):%+v", res) + return + } + + // 預期:stub 無真 nef → converter 500 → visionA 包成 ErrConverterUnavailable + t.Logf("Promote 如預期失敗(stub 無真 nef):%v", perr) + if !errors.Is(perr, ErrConverterUnavailable) { + // 容忍其他合理 sentinel(如 ErrJobNotCompleted / ErrFAAUnavailable),但記下供人工判讀。 + t.Logf("注意:Promote 錯誤非 ErrConverterUnavailable(得 %v)。"+ + "確認真服務 500 body 是否符合手動實測的 internal_error 格式;"+ + "若 converter 回了不同 status 此處需對齊。", perr) + } + // 關鍵 contract:visionA 回了「分類好的 error」而非 nil result + nil err(不崩)。 + if res != nil { + t.Errorf("Promote 失敗時 result 應為 nil,得 %+v", res) + } +} + +// ========================================================================== +// E2E #3:GetResult 解析真 stub 輸出(15 bytes "STUB_NEF_OUTPUT") +// ========================================================================== + +// TestRealConverter_GetResultStubOutput 驗 visionA GetResult 對真 stub 服務的 streaming 解析: +// +// stub 模式 GET /result 回 15 bytes 字串 "STUB_NEF_OUTPUT"(非真 nef)。 +// visionA GetResult 必須: +// - 不 panic +// - 回 io.ReadCloser stream + DownloadMetadata(能讀出 body、Close 正常) +// +// 這驗的是「真服務 result endpoint 的 streaming response 解析」——mock 用固定 marker, +// 此處用真服務的實際佔位輸出。 +func TestRealConverter_GetResultStubOutput(t *testing.T) { + env := requireRealConvEnv(t) + client := newRealConverterClient(t, env) + + body, contentType, _ := buildRealInitBody(t, env) + initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer initCancel() + cj, err := client.InitJob(initCtx, InitConverterJobReq{ + UserID: realConvTestUserID, + Platform: "520", + SourceFilename: filepath.Base(env.onnxPath), + Body: bytes.NewReader(body), + BodyContentType: contentType, + }) + if err != nil { + t.Fatalf("InitJob 失敗(GetResult 測試前置):%v", err) + } + final := pollUntilTerminal(t, client, cj.JobID) + if final.Status != "completed" { + t.Skipf("job 未 completed(status=%s),跳過 GetResult 驗證", final.Status) + } + + resCtx, resCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer resCancel() + stream, meta, gerr := client.GetResult(resCtx, cj.JobID) + if gerr != nil { + // stub 模式下 GET /result 預期回 200 + 佔位輸出;若回錯誤記下供人工判讀 + t.Logf("注意:GetResult 失敗(stub 預期成功回佔位輸出):%v", gerr) + // 仍驗「失敗時不回半套」:stream 應為 nil + if stream != nil { + _ = stream.Close() + t.Errorf("GetResult 失敗時 stream 應為 nil") + } + return + } + defer stream.Close() + + got, rerr := io.ReadAll(io.LimitReader(stream, 1024)) + if rerr != nil { + t.Fatalf("讀 GetResult stream 失敗:%v", rerr) + } + t.Logf("GetResult OK:讀到 %d bytes,content_type=%q filename=%q content_length=%d。內容=%q", + len(got), meta.ContentType, meta.Filename, meta.ContentLength, string(got)) + + // stub 真實行為:15 bytes "STUB_NEF_OUTPUT"(手動實測)。不硬斷言內容(轉檔端可能改), + // 重點是「解析 streaming response 成功、能讀出 body」。 + if len(got) == 0 { + t.Errorf("GetResult stream 讀到 0 bytes(stub 預期回 15 bytes 佔位輸出)") + } +} + +// ========================================================================== +// helpers +// ========================================================================== + +func derefInt(p *int) int { + if p == nil { + return -1 + } + return *p +} diff --git a/visionA-backend/internal/oidc/oidc.go b/visionA-backend/internal/oidc/oidc.go index 3c7a000..2959644 100644 --- a/visionA-backend/internal/oidc/oidc.go +++ b/visionA-backend/internal/oidc/oidc.go @@ -34,6 +34,17 @@ type ProviderConfig struct { // Scopes 是 OIDC scope 清單,預設 ["openid", "email", "profile"]。 // 若為空,NewProvider 會套用預設值。 Scopes []string + + // PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login` 參數。 + // + // - true → 每次登入都強制 IdP 重新認證(忽略既有 SSO session,要求重輸帳密)。 + // - false → 不帶 prompt,沿用 IdP 既有 session(標準 SSO 體驗),為預設值。 + // + // `prompt=login` 是 OIDC Core §3.1.2.1 標準參數,由 caller(OIDC discovery 不一定 + // 在 prompt_values_supported 列出,但 Core 標準參數 IdP 多半支援)。設成 config 可控 + // 是為了讓「強制重新認證」在不同部署間可開可關 — 例如 stage 要每次都問、prod 視 UX 決定。 + // 對齊 VISIONA_OIDC_PROMPT_LOGIN。 + PromptLogin bool } // DefaultScopes 是 OIDC 標準 scope 集合,能取得 sub / email / name 三個 claim。 diff --git a/visionA-backend/internal/oidc/provider.go b/visionA-backend/internal/oidc/provider.go index 8d2886e..fd4c778 100644 --- a/visionA-backend/internal/oidc/provider.go +++ b/visionA-backend/internal/oidc/provider.go @@ -136,13 +136,19 @@ func validateConfig(cfg *ProviderConfig) error { // // 用 oauth2.Config.AuthCodeURL 組 URL,加上 PKCE 與 nonce 兩個額外參數 // (oauth2 lib 原生不知道這兩個東西,需以 oauth2.SetAuthURLParam 注入)。 +// +// 若 cfg.PromptLogin 為 true,額外帶 OIDC `prompt=login`(Core §3.1.2.1): +// 讓 IdP 忽略既有 SSO session、每次都強制使用者重新認證。預設不帶(沿用 SSO session)。 func (p *provider) AuthorizationURL(state, nonce, codeChallenge string) string { - return p.oauth2Cfg.AuthCodeURL( - state, + opts := []oauth2.AuthCodeOption{ oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("nonce", nonce), - ) + } + if p.cfg.PromptLogin { + opts = append(opts, oauth2.SetAuthURLParam("prompt", "login")) + } + return p.oauth2Cfg.AuthCodeURL(state, opts...) } // ExchangeCode 實作 Provider.ExchangeCode。 diff --git a/visionA-backend/internal/oidc/provider_test.go b/visionA-backend/internal/oidc/provider_test.go index abc1375..aa291a2 100644 --- a/visionA-backend/internal/oidc/provider_test.go +++ b/visionA-backend/internal/oidc/provider_test.go @@ -331,6 +331,50 @@ func TestAuthorizationURL_Format(t *testing.T) { // authorization_endpoint 應指向 fake server 的 /authorize assert.Equal(t, fake.issuer()+"/authorize", u.Scheme+"://"+u.Host+u.Path) + + // 預設 PromptLogin=false → authorize URL **不**帶 prompt 參數(沿用 SSO session)。 + assert.Empty(t, q.Get("prompt"), "預設不應帶 prompt 參數") +} + +// TestAuthorizationURL_PromptLogin 驗 PromptLogin config 開關對 authorize URL 的影響。 +func TestAuthorizationURL_PromptLogin(t *testing.T) { + fake := newFakeOIDC(t, testClientID) + + newProviderWithPrompt := func(t *testing.T, promptLogin bool) Provider { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + p, err := NewProvider(ctx, ProviderConfig{ + IssuerURL: fake.issuer(), + ClientID: testClientID, + ClientSecret: testClientSecret, + RedirectURL: testRedirect, + PromptLogin: promptLogin, + }) + require.NoError(t, err) + return p + } + + state, _ := GenerateState() + nonce, _ := GenerateNonce() + verifier, _ := GenerateCodeVerifier() + challenge := CodeChallenge(verifier) + + t.Run("enabled adds prompt=login", func(t *testing.T) { + p := newProviderWithPrompt(t, true) + u, err := url.Parse(p.AuthorizationURL(state, nonce, challenge)) + require.NoError(t, err) + assert.Equal(t, "login", u.Query().Get("prompt"), + "PromptLogin=true 時 authorize URL 應帶 prompt=login") + }) + + t.Run("disabled omits prompt", func(t *testing.T) { + p := newProviderWithPrompt(t, false) + u, err := url.Parse(p.AuthorizationURL(state, nonce, challenge)) + require.NoError(t, err) + assert.Empty(t, u.Query().Get("prompt"), + "PromptLogin=false 時 authorize URL 不應帶 prompt 參數") + }) } func TestExchangeCode_Success(t *testing.T) { @@ -658,9 +702,9 @@ func TestExchangeCode_PublicClientNoSecretSent(t *testing.T) { // 取出 fake server 觀察到的 token request var ( - form url.Values - auth string - ok bool + form url.Values + auth string + ok bool ) fake.withState(func() { form = fake.lastTokenForm diff --git a/visionA-frontend/src/stores/auth-store.test.ts b/visionA-frontend/src/stores/auth-store.test.ts index 87fbc93..1bcdbfb 100644 --- a/visionA-frontend/src/stores/auth-store.test.ts +++ b/visionA-frontend/src/stores/auth-store.test.ts @@ -18,6 +18,9 @@ import { useAuthStore } from "./auth-store"; * - fetchMe 等同 hydrate * - logout 200 → 清 user * - logout backend 失敗仍清前端 user(best-effort) + * - logout 帶 idp_logout → 連動 MC 登出(建隱藏 iframe) + * - logout 無 idp_logout → 不建 iframe(向下相容) + * - logout iframe 被擋(onload 不觸發)→ fallback timer 後仍 resolve * - 不再寫入 localStorage(OF6 安全債清理驗證) */ @@ -49,6 +52,8 @@ describe("auth-store", () => { afterEach(() => { vi.restoreAllMocks(); window.localStorage.clear(); + // 清掉任何測試殘留的 iframe(避免污染下一個測試的 querySelector) + document.querySelectorAll("iframe").forEach((el) => el.remove()); }); /* ------------------------------------------------------------------------ */ @@ -250,6 +255,110 @@ describe("auth-store", () => { await useAuthStore.getState().logout(); expect(useAuthStore.getState().user).toBeNull(); + // backend 失敗 → 拿不到 idp_logout → 不該建 iframe(退回只清 visionA) + expect(document.querySelector("iframe")).toBeNull(); + }); + + /* ------------------------------------------------------------------------ */ + /* logout — IdP(Member Center)連動登出 */ + /* ------------------------------------------------------------------------ */ + + it("logout:response 帶 idp_logout → 建隱藏 iframe 觸發 MC 登出,最終 resolve 並清掉 iframe", async () => { + useAuthStore.getState()._setUser({ id: "u", email: "e@x", name: "U" }); + + const mcLogoutUrl = "https://stage-9527.innovedus.com:7880/account/logout"; + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { + success: true, + idp_logout: { url: mcLogoutUrl, method: "GET" }, + }, + }), + ); + + // jsdom 不會真的載入 cross-origin iframe(onload 不觸發)→ 走 fallback timer。 + // 用 fake timers 控制:logout 的 promise 會等到 timer 觸發才 resolve。 + vi.useFakeTimers(); + try { + const logoutPromise = useAuthStore.getState().logout(); + + // 等 backend POST + set state 完成(microtask flush),iframe 此時應已建立 + await vi.advanceTimersByTimeAsync(0); + + const iframe = document.querySelector("iframe"); + expect(iframe).not.toBeNull(); + expect(iframe?.getAttribute("src")).toBe(mcLogoutUrl); + expect((iframe as HTMLIFrameElement).style.display).toBe("none"); + + // 前端 user 已清(在連動 MC 前就清了) + expect(useAuthStore.getState().user).toBeNull(); + + // 推進 fallback timer(2000ms)→ logout 應 resolve、iframe 被移除 + await vi.advanceTimersByTimeAsync(2000); + await logoutPromise; + + expect(document.querySelector("iframe")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("logout:iframe load 事件觸發時提早 resolve(不必等滿 fallback timer)", async () => { + useAuthStore.getState()._setUser({ id: "u" }); + + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { + success: true, + idp_logout: { url: "https://mc.example/account/logout" }, + }, + }), + ); + + const logoutPromise = useAuthStore.getState().logout(); + + // 等 iframe 建立後(backend POST → res.text() → set state → triggerIdpLogout + // 之間有多個 microtask + macrotask),手動派發 load 事件模擬「MC 同源頁載入完成」 + const iframe = await vi.waitFor(() => { + const el = document.querySelector("iframe"); + if (!el) throw new Error("iframe not yet created"); + return el; + }); + iframe.dispatchEvent(new Event("load")); + + await logoutPromise; + // load 觸發後 iframe 應被清掉 + expect(document.querySelector("iframe")).toBeNull(); + expect(useAuthStore.getState().user).toBeNull(); + }); + + it("logout:response 無 idp_logout → 維持舊行為,不建 iframe(向下相容)", async () => { + useAuthStore.getState()._setUser({ id: "u", email: "e@x", name: "U" }); + + fetchMock.mockResolvedValue( + jsonResponse(200, { success: true, data: { success: true } }), + ); + + await useAuthStore.getState().logout(); + + expect(useAuthStore.getState().user).toBeNull(); + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("logout:idp_logout.url 為空字串 → 視為無,不建 iframe", async () => { + useAuthStore.getState()._setUser({ id: "u" }); + + fetchMock.mockResolvedValue( + jsonResponse(200, { + success: true, + data: { success: true, idp_logout: { url: "", method: "GET" } }, + }), + ); + + await useAuthStore.getState().logout(); + expect(document.querySelector("iframe")).toBeNull(); }); /* ------------------------------------------------------------------------ */ diff --git a/visionA-frontend/src/stores/auth-store.ts b/visionA-frontend/src/stores/auth-store.ts index f164e78..e13f01c 100644 --- a/visionA-frontend/src/stores/auth-store.ts +++ b/visionA-frontend/src/stores/auth-store.ts @@ -9,6 +9,8 @@ * 職責: * - 持有當前使用者(從 backend `GET /api/auth/me` 取得) * - 提供 hydrate(app boot)/ fetchMe(手動 refresh)/ logout actions + * - logout 連動 IdP(Member Center)登出:清掉 MC session 讓使用者能換帳號 + * (backend logout response 回 `idp_logout.url`,前端用隱藏 iframe 背景觸發) * * BFF 模式重點: * - **frontend 完全看不到 OIDC token**(access_token / id_token 由 backend cookie session 持有) @@ -45,6 +47,36 @@ interface MeResponse { name?: string; } +/** + * `POST /api/auth/logout` 的 envelope `data` payload。 + * 對齊 visionA-backend logout handler: + * { success: true, idp_logout?: { url, method } } + * + * - response 本身已清 visionA session cookie(前端不需再打) + * - `idp_logout` 為「連動 IdP(Member Center)登出」的資訊;**optional**: + * backend 未設 logout URL 時整個欄位缺席 → 前端維持「只清 visionA」的舊行為 + * - `method` 目前固定為 GET(stage MC 直接導向即可),保留欄位以容忍未來改 POST + */ +interface LogoutResponse { + success?: boolean; + idp_logout?: { + url: string; + method?: string; + }; +} + +/** + * 觸發 IdP(Member Center)登出後、再導向 visionA 登入頁的等待時間(毫秒)。 + * + * 為什麼需要 fallback timer: + * - 隱藏 iframe 載入 MC :7880 logout 可能被 `X-Frame-Options` / CSP `frame-ancestors` + * 擋下 → `onload` 永遠不觸發;不能讓使用者卡死,所以無論如何 timer 到就往下走。 + * - 即使 iframe 被擋,**瀏覽器仍會送出該 GET 請求**(X-Frame-Options 只擋「渲染」, + * 不擋請求發出),MC 端收到 logout request 後仍會清掉自己的 session cookie, + * 所以背景清登出的目的多半仍達成。 + */ +const IDP_LOGOUT_FALLBACK_MS = 2000; + /* -------------------------------------------------------------------------- */ /* Types */ /* -------------------------------------------------------------------------- */ @@ -72,8 +104,17 @@ export interface AuthState { fetchMe: () => Promise; /** - * 登出:呼叫 backend `POST /api/auth/logout` 清 server session + cookie, - * 不論成功與否最終都清前端 user state。 + * 登出: + * 1. 呼叫 backend `POST /api/auth/logout` 清 server session + visiona_session cookie + * 2. 清前端 user state(不論 backend 成功與否,best-effort) + * 3. 若 response 帶 `idp_logout`,背景連動 Member Center 登出(隱藏 iframe), + * 清掉 MC session 讓使用者能換帳號;MC 連動失敗不影響登出結果 + * + * 向下相容:response 沒有 `idp_logout`(backend 未設 logout URL)時, + * 維持舊行為(只清 visionA、不連動 MC)。 + * + * resolve 後 caller 才導向 /login —— 此時 MC session 已(盡力)清完, + * 下次登入會被要求重新輸入帳密。 */ logout: () => Promise; @@ -98,6 +139,72 @@ function mapMeToUser(me: MeResponse): User { }; } +/* -------------------------------------------------------------------------- */ +/* IdP(Member Center)連動登出 */ +/* -------------------------------------------------------------------------- */ + +/** + * 用「不離開 visionA」的方式觸發 Member Center 登出,清掉 MC 的 session + * cookie,讓使用者下次登入時會被要求重新輸入帳密(才能換帳號)。 + * + * 做法:動態建一個隱藏 iframe 指向 `url`(GET),瀏覽器送出該請求清 MC session。 + * 全程使用者停留在 visionA、MC 登出在背景發生,不會把使用者甩到 MC 首頁 + * (stage 的 MC 舊版不支援 returnUrl,直接 `location = url` 會卡在 MC 首頁)。 + * + * resolve 時機(取最先發生者): + * 1. iframe `load` 事件觸發(MC 同源頁面載入完成、或瀏覽器判定載入結束) + * 2. fallback timer 到(iframe 被 X-Frame-Options / CSP 擋導致 load 不觸發時的保險) + * + * resolve 後會清掉 iframe(從 DOM 移除),避免殘留節點。 + * + * 容錯: + * - 非瀏覽器環境(SSR / 測試無 document)→ 直接 resolve,不做任何事 + * - 建立 iframe 過程任何例外 → 直接 resolve(登出不該因 MC 連動失敗而卡住) + */ +function triggerIdpLogout(url: string): Promise { + // SSR / 無 DOM 環境(理論上 logout 只在 client 觸發,但保險處理) + if (typeof document === "undefined") { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let settled = false; + let iframe: HTMLIFrameElement | null = null; + let timerId: ReturnType | null = null; + + const cleanup = () => { + if (settled) return; + settled = true; + if (timerId !== null) clearTimeout(timerId); + // 從 DOM 移除 iframe(保險用 optional chaining,避免 race) + if (iframe && iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + resolve(); + }; + + try { + iframe = document.createElement("iframe"); + iframe.style.display = "none"; + // 安全性:限制 iframe 能力。allow-same-origin 必須保留,否則部分瀏覽器 + // 不會送 MC 的 session cookie(cross-site cookie 需要 same-origin 上下文)。 + iframe.setAttribute("aria-hidden", "true"); + iframe.setAttribute("tabindex", "-1"); + iframe.addEventListener("load", cleanup, { once: true }); + // 設 src 觸發載入(GET) + iframe.src = url; + document.body.appendChild(iframe); + } catch { + // 建立 / 掛載失敗 → 不阻擋登出流程 + cleanup(); + return; + } + + // Fallback:iframe 被擋(onload 不觸發)時,timer 到就往下走,避免卡死 + timerId = setTimeout(cleanup, IDP_LOGOUT_FALLBACK_MS); + }); +} + /* -------------------------------------------------------------------------- */ /* Store */ /* -------------------------------------------------------------------------- */ @@ -139,15 +246,31 @@ export const useAuthStore = create()((set, get) => ({ logout: async () => { set({ isLoading: true }); + + let idpLogoutUrl: string | null = null; try { - // backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期 - await api.post("/api/auth/logout"); + // backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期, + // 並(若有設定)回 idp_logout = { url, method } 供前端連動 MC 登出。 + const res = await api.post("/api/auth/logout"); + const url = res?.idp_logout?.url; + // 容忍 idp_logout 不存在 / url 非字串 / 空字串(向下相容舊 backend) + if (typeof url === "string" && url.length > 0) { + idpLogoutUrl = url; + } } catch { // 即使 backend 失敗(網路 / 5xx)— 仍然清前端 state, // 否則使用者卡在「無法登出」。下次發 API 若 cookie 還在就照樣帶, - // backend session 已 best-effort 嘗試清除。 + // backend session 已 best-effort 嘗試清除。失敗時拿不到 idp_logout, + // 退回「只清 visionA」行為。 } + + // 先清前端 user state(讓 UI 立即反映已登出),再背景連動 MC 登出。 set({ user: null, isLoading: false, error: null }); + + // 有 idp_logout 才連動 MC;await 確保 caller 導向 /login 前 MC session 已(盡力)清完。 + if (idpLogoutUrl) { + await triggerIdpLogout(idpLogoutUrl); + } }, _setUser: (user) => {