feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 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 空時用 <sub>@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) <noreply@anthropic.com>
This commit is contained in:
parent
dc1c0dbee4
commit
c2f0b1549e
183
docs/autoflow/04-architecture/converter-promote-oauth-handoff.md
Normal file
183
docs/autoflow/04-architecture/converter-promote-oauth-handoff.md
Normal file
@ -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=<faaAudience,對應 config.fileAccessAgent.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 時要連這段註解一起改掉。
|
||||
189
docs/autoflow/04-architecture/mc-email-claim-handoff.md
Normal file
189
docs/autoflow/04-architecture/mc-email-claim-handoff.md
Normal file
@ -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<TUser>` 只會放入:
|
||||
- `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<ApplicationUser, ApplicationRole>
|
||||
{
|
||||
public CustomUserClaimsPrincipalFactory(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<ApplicationRole> roleManager,
|
||||
IOptions<IdentityOptions> options)
|
||||
: base(userManager, roleManager, options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<ClaimsIdentity> 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<ApplicationUser, ApplicationRole>(options => { /* 既有設定不變 */ })
|
||||
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
||||
.AddDefaultTokenProviders()
|
||||
.AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory>(); // ← 新增這行
|
||||
```
|
||||
|
||||
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 後到 <https://jwt.io> 或自行 decode,確認 payload 含:
|
||||
- `email`: `<user 的 email>`
|
||||
- `email_verified`: `true` / `false`(若採 §5.3)
|
||||
- `sub`: `<user id>`
|
||||
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 已一致,沿用同一常數即可)。
|
||||
@ -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 欄位
|
||||
|
||||
@ -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,6 +436,7 @@ func main() {
|
||||
OIDCProvider: oidcProvider,
|
||||
SessionManager: userSessionMgr,
|
||||
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
|
||||
OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空=logout 只清本地、不連動 MC(向下相容)
|
||||
UserStore: userStore, // DB-on FK 收尾 #1:OIDC callback provision users 列
|
||||
})
|
||||
|
||||
|
||||
@ -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)。
|
||||
//
|
||||
|
||||
@ -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 缺時,用 "<sub>@<fallbackEmailDomain>" 當 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 現況)→ 用 "<sub>@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
|
||||
// 補成 "<sub>@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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/oidc"
|
||||
"visiona-backend/internal/user"
|
||||
"visiona-backend/internal/usersession"
|
||||
)
|
||||
|
||||
@ -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 是 "<sub>@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{}
|
||||
|
||||
@ -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(例如查詢使用者組織、推送通知等)。
|
||||
//
|
||||
|
||||
@ -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", ""),
|
||||
|
||||
506
visionA-backend/internal/conversion/real_chain_e2e_test.go
Normal file
506
visionA-backend/internal/conversion/real_chain_e2e_test.go
Normal file
@ -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:<pw>@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:<pw>@<pg-host>: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 可重用)。
|
||||
// ==========================================================================
|
||||
491
visionA-backend/internal/conversion/real_converter_e2e_test.go
Normal file
491
visionA-backend/internal/conversion/real_converter_e2e_test.go
Normal file
@ -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 取 "<n>.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
|
||||
}
|
||||
@ -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。
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
@ -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<void>;
|
||||
|
||||
/**
|
||||
* 登出:呼叫 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<void>;
|
||||
|
||||
@ -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<void> {
|
||||
// SSR / 無 DOM 環境(理論上 logout 只在 client 觸發,但保險處理)
|
||||
if (typeof document === "undefined") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let iframe: HTMLIFrameElement | null = null;
|
||||
let timerId: ReturnType<typeof setTimeout> | 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<AuthState>()((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<LogoutResponse>("/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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user