Compare commits

...

3 Commits

Author SHA1 Message Date
c2f0b1549e 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>
2026-06-27 03:18:59 +08:00
dc1c0dbee4 feat(visionA-frontend): B4 model 詳細頁顯示 metadata(input_shape/classes/framework)
對應 backend B4:model 詳細頁顯示轉檔帶來的 metadata。
- Model 型別加 inputShape/classes/framework(optional);normalize 雙吃 snake/camel
- input_shape 顯示成 1 × 3 × 224 × 224;classes 前 8 個 + N 折疊;framework 直接顯示
- 有值才顯示(沒值不渲染空白);中英 i18n
- tsc / lint / next build 綠

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:37:36 +08:00
cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:35 +08:00
62 changed files with 6660 additions and 124 deletions

4
.gitignore vendored
View File

@ -62,3 +62,7 @@ local-tool/visiona-local/payload/
# 共享文件PRD / 設計 / 架構 / 交付)走 docs/ 進 git
.autoflow/
graphify-out/
# sub-agent 工作 logper-branch、不進 git
.autoflow-logs/
**/.autoflow-logs/

View File

@ -58,18 +58,30 @@ server {
# /healthz 例外Docker healthcheck 從 container 內打 localhost/healthz
# Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任)
# 限制 source = 127.0.0.0/8 防止外部偽造 Host 跳過白名單
#
# ⚠️ 為什麼這層不能用 server-level `return 444`(修正前的 bug
# server context 的 `return` 在 nginx rewrite phase 執行會「先於」location
# 匹配短路掉所有請求 —— 包含這個 `location = /healthz`。修正前 default_server
# 結尾寫 `return 444;`,導致 docker healthcheckHost: localhost、來源 127.0.0.1
# 的 /healthz 也被打成 444 → container 長期 unhealthyfalse 444
# 正解:把 catch-all 444 收進 `location /`,讓 exact-match `location = /healthz`
# 依 nginx location 優先序勝出。
location = /healthz {
allow 127.0.0.0/8;
allow ::1/128;
deny all;
# 直接內部回 200不轉到 api-server避免 api-server 也要實作 /healthz
access_log off;
# 直接內部回 200不轉到 api-server淺層只證明 nginx 活著)
return 200 "ok\n";
add_header Content-Type "text/plain" always;
}
# 其他任何 host header 不符合白名單 → 444 close不回 response 給攻擊者反饋)
# 用 location / 包起來(而非 server-level return才不會 shadow 掉上面的 /healthz。
location / {
return 444;
}
}
server {
listen 80;
@ -109,7 +121,11 @@ server {
# 注意:不在這層加 HSTSHTTPS termination 在公司 host nginx由那層加
# ============================================================
# 健康檢查 — 不打到 backenddocker healthcheck 用
# 健康檢查(淺層)— 不打到 backend
# 用途「nginx 程序活著」的最廉價證明。docker healthcheck走 default_server
# 那條)與「只想確認反代層在線」的外部探針用這條。
# ⚠️ 注意:這條「不」反映 DB / Redis 健康 —— load balancer 若要在 DB 掛掉時
# 把本實例踢出輪替,必須打下面的 /healthz/deep不能打這條。
# ============================================================
location = /healthz {
access_log off;
@ -117,6 +133,38 @@ server {
add_header Content-Type text/plain;
}
# ============================================================
# 健康檢查(深層)— proxy 到 backend /healthz會 ping Postgres + Redis
# 用途load balancer / 監控的 readiness 探針。
# - PG + Redis 都健康 → 200 {"status":"ok","checks":{"postgres":"ok","redis":"ok"}}
# - 任一依賴 ping 失敗 → 503 {"status":"unavailable","checks":{...:"down"}}
# 讓上游 LB 在 DB 掛掉時把本實例拉出輪替,而非繼續送流量進來碰 503 / 假資料。
#
# 設計取捨:
# - proxy_pass 改寫 path → 後端命中的是 /healthz後端只實作這一個健康端點
# - timeout 全部壓短2s健康探針不該 hangbackend 自身 ping 逾時也是 2s
# 這層再加一道 nginx 短逾時,避免單一探針卡住 worker。
# - access_log off高頻探針不洗版 access log與淺層一致
# - 不繼承 server-level 的 proxy_read_timeout 3600s那是給長連線用的
# ============================================================
location = /healthz/deep {
access_log off;
proxy_pass http://visiona_api/healthz;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# 健康探針要快、不該 hang —— 壓短所有 timeout覆蓋 server-level 3600s
proxy_connect_timeout 2s;
proxy_read_timeout 2s;
proxy_send_timeout 2s;
# 探針結果不可被任何中間層 cache
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-store" always;
}
# ============================================================
# /api/* → api-server :3721
# 包含 /api/auth/*OIDC callback、/api/devices、/api/models、/api/pairing 等

View File

@ -0,0 +1,252 @@
# B4 交接檔:轉檔服務端串接 `analysis_info`input_shape metadata
> **狀態**Draft — 給 `kneron_model_converter` repo 的人實作
> **作者**Architect AgentvisionA
> **最後更新**2026-06-21
> **跨 repo**:本檔在 visionA repo`docs/autoflow/04-architecture/`),描述的改動全部在 `kneron_model_converter` repo
---
## 1. 目標(一句話)
`kneron_model_converter`**`GET /api/v1/jobs/{id}` response 帶上 top-level `analysis_info` 物件**(含 input_shape / 維度 metadata使 visionA 能在轉檔完成後顯示 model 的 input_shape。
### 為什麼這份交接這麼短的工作量
**值已經 parse 出來了,只差「往上串」。** bie worker`services/workers/bie/core.py`)在量化階段已從 ONNX graph 讀出 `batch_size / channels / height / width` 並放進 `process_bie_core()` 的 return dict 的 `analysis_info` 欄位L87-93。但這個 dict 從 worker 回到 consumer 後就被丟掉了 —— consumer 推 done event 時沒帶它,所以一路到 `GET /jobs/{id}` response 都沒有這個資訊。
**visionA 端已全部就緒、等接。** visionA backend 已實作好 `analysis_info` 的解析(`converter_client.go`frontend 也已實作顯示。轉檔端串好的那一刻visionA 自動就能顯示,不需要 visionA 再改任何 code。轉檔端**沒串好**時 visionA 維持空白、不報錯(全 optional
---
## 2. 完整鏈路缺口表
資料從 bie worker 一路傳到 `GET /jobs/{id}` response 要經過 4 個檔。目前**第 1 個檔已有值**,後面 3 個檔把值「丟掉」了。
| # | 檔案 | 現況 | 要改什麼 |
|---|------|------|---------|
| 1 | `services/workers/bie/core.py` | ✅ **已 parse**`process_bie_core()` return dict 含 `analysis_info`L87-93 | **不用改**(可選:直接補一個 `input_shape` 陣列欄位,見 §4 |
| 2 | `services/workers/consumer.py` | ❌ `_process_message` 拿到 `result`L206含 analysis_info`_push_done(job_id, "ok")`L212沒帶它`_push_done`L175-186組的 done message 也沒這欄位 | **改**:把 `result["analysis_info"]` 串進 done message |
| 3 | `apps/task-scheduler/src/services/doneListener.js` | ❌ 解析 done message 只取 `{ job_id, step, result, reason }`L102丟掉 analysis_info | **改**:解析出 `analysis_info`,傳給 `jobService.advanceJob` |
| 4a | `apps/task-scheduler/src/services/jobService.js` | ❌ `advanceJob(jobId, completedStage)`L246不接收也不持久化 analysis_info | **改**`advanceJob` 多收一個 analysis_info 參數,寫進 job recordRedis |
| 4b | `apps/task-scheduler/src/routes/v1/jobs.js` | ❌ `serializeJobForResponse(job)`L151-227組 GET response body 沒有 `analysis_info` 欄位 | **改**:在 response body 加 top-level `analysis_info`completed 時帶,否則 null |
> **關鍵理解**done event 是「stage 完成事件」bie 是 pipeline 第 2 階段onnx → **bie** → nef。analysis_info 是 bie 階段產出的。**bie stage 完成時就要把 analysis_info 持久化到 job record**,這樣即使後面還有 nef 階段、job record 也已經帶著 analysis_info等到整個 job COMPLETED 後 `GET /jobs/{id}` 就讀得到。
---
## 3. Result schema**一字不差對齊 visionA backend 已定案的讀取格式**
visionA backend 在 `GET /api/v1/jobs/{id}` response 的 **top-level `analysis_info` 物件**(與 `input`/`parameters`/`error`/`result_object_keys` 同層)讀 metadata。
### 3.1 目標 JSONGET /jobs/{id} response節錄
```json
{
"job_id": "abc-123",
"status": "completed",
"stage": null,
"progress": 100,
"input": { "...": "..." },
"result_object_keys": { "...": "..." },
"parameters": { "...": "..." },
"error": null,
"analysis_info": {
"input_shape": [1, 3, 224, 224],
"batch_size": 1,
"channels": 3,
"height": 224,
"width": 224,
"classes": ["face", "person"],
"framework": "onnx"
}
}
```
### 3.2 欄位定義(對齊 visionA `converterAnalysisInfoJSON`
| 欄位 | 型別 | 必填 | 說明 |
|------|------|------|------|
| `input_shape` | `[]int` | optional | **優先表示法**。NCHW 順序,如 `[1, 3, 224, 224]`。給了這個visionA 直接用 |
| `batch_size` | `int` | optional | **後備**`input_shape` 缺時用這 4 個維度組 |
| `channels` | `int` | optional | 後備維度 |
| `height` | `int` | optional | 後備維度 |
| `width` | `int` | optional | 後備維度 |
| `classes` | `[]string` | optional | 分類標籤,如 `["face", "person"]` |
| `framework` | `string` | optional | 來源框架,如 `"onnx"` |
### 3.3 visionA 端對映優先序(已實作、不會變)
visionA 收到 `analysis_info` 後,這樣決定 input_shape
1. `input_shape` 陣列**非空** → 直接用
2. 否則 `batch_size`/`channels`/`height`/`width` 四維**全齊** → 組 `[batch, channel, height, width]`NCHW
3. 四維**缺任一** → `nil`(不亂組半套維度,防呆)
4. 都沒有 → `nil`
**全 optional**:轉檔端整個 `analysis_info` 不給、或給空物件 `{}`、或給部分欄位visionA 都不報錯,維持空白顯示。
---
## 4. 兩種表示法都接受(轉檔端可二選一)
bie worker 現在原生輸出的是**拆開的四維**`batch_size`/`channels`/`height`/`width`core.py L89-92沒有 `input_shape` 陣列。
visionA **兩種都接**
| 轉檔端給法 | visionA 處理 | 建議 |
|-----------|-------------|------|
| (a) 直接給 `input_shape: [1,3,224,224]` 陣列 | 走優先序 #1,直接用 | ✅ **更推薦** — 語意最明確、未來若有非 4 維 shape如 NLP 模型)也能表達 |
| (b) 給拆開的 `batch_size`/`channels`/`height`/`width` 四維 | 走優先序 #2,組成 NCHW | ✅ 也可 — bie worker 現成欄位,改動最小 |
**最省事路徑(推薦)**consumer / doneListener / jobService 在傳遞時,**原封不動把 bie worker 的 `analysis_info` dict 往上傳**(裡面已含 `batch_size`/`channels`/`height`/`width`),最後 `serializeJobForResponse` 直接吐出去。visionA 走後備路徑 #2 即可。如果順手在 core.py 補一個 `input_shape` 陣列欄位更好,但非必要。
> ⚠️ 注意bie worker 的 `analysis_info` 還有一個 `input_name` 欄位core.py L88。visionA 不讀它,無害,可一起往上傳、也可在某一層 drop 掉,都行。
---
## 5. 各檔要改的細節
> 以下是「建議改法」,實際命名 / 風格依轉檔端 repo 慣例。核心是**讓 analysis_info 一路傳到 job record再從 GET response 吐出**。
### 5.1 `services/workers/consumer.py`(檔 #2
`_push_done` 目前不帶 metadata。把 bie 的 analysis_info 串進去:
```python
def _push_done(self, job_id, result, reason=None, analysis_info=None):
message = {
"job_id": job_id,
"step": self.stage,
"result": result,
"completed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
}
if reason:
message["reason"] = reason
if analysis_info: # ← 新增
message["analysis_info"] = analysis_info
self.client.xadd("queue:done", {"data": json.dumps(message)})
```
`_process_message` 成功路徑L211-212`result` 裡的 analysis_info 帶上:
```python
result = self.process_fn(input_paths, output_path, parameters)
self._upload_output(job_id, job_dir)
logger.info(...)
self._push_done(job_id, "ok", analysis_info=result.get("analysis_info")) # ← 改
```
> 只有 bie stage 的 `result` 有 analysis_infoonnx / nef stage 的 `result.get("analysis_info")``None``_push_done``if analysis_info` 自然 skip無害。
### 5.2 `apps/task-scheduler/src/services/doneListener.js`(檔 #3
L101-102 解析 done message 時多解一個欄位,並傳給 advanceJob
```js
const data = JSON.parse(fields[1]);
const { job_id, step, result, reason, analysis_info } = data; // ← 多解 analysis_info
if (result === 'ok') {
await jobService.advanceJob(job_id, step, analysis_info); // ← 多傳一個參數
} else {
await jobService.failJob(job_id, step, reason || 'Unknown error');
}
```
### 5.3 `apps/task-scheduler/src/services/jobService.js`(檔 #4a
`advanceJob` 多收 analysis_info寫進 job record持久化到 Redis
```js
async function advanceJob(jobId, completedStage, analysisInfo) { // ← 多收參數
const job = await getJob(jobId);
if (!job) { /* ...既有... */ return; }
const currentIndex = STAGES.indexOf(completedStage);
if (currentIndex < 0) { /* ...既有... */ return; }
recordStageComplete(job, completedStage);
// ← 新增bie stage 帶 analysis_info 時,持久化到 job record
// bie 是中間 stage先寫進 record等 job COMPLETED 後 GET 就讀得到)
if (analysisInfo && typeof analysisInfo === 'object') {
job.analysis_info = analysisInfo;
}
// ...既有的推進 / COMPLETED 邏輯不變setJob 會把 job.analysis_info 一起寫進 Redis...
}
```
> 為什麼寫在 `advanceJob` 而非等 COMPLETED 才寫bie 完成時呼叫的是 `advanceJob(jobId, 'bie', analysisInfo)`,此時 stage 推進到 nef、job 還沒 COMPLETED。把 analysis_info 在這一刻寫進 record後續 nef 完成走 COMPLETED branch 時 record 已帶著它(`setJob` 是整個 record 序列化寫 Redis不會掉欄位
### 5.4 `apps/task-scheduler/src/routes/v1/jobs.js`(檔 #4b
`serializeJobForResponse(job)`L151-227的 return 物件加一個 top-level `analysis_info`
```js
return {
job_id: job.job_id,
user_id: job.user_id || null,
status: externalStatus,
// ...既有欄位...
parameters,
metadata,
analysis_info: // ← 新增 top-level 欄位
job.analysis_info && typeof job.analysis_info === 'object'
? job.analysis_info
: null,
created_by_client_id: job.created_by_client_id || null,
};
```
> - 放 top-level`input`/`parameters`/`error` 同層),**不要**塞進 `metadata``parameters`visionA 讀的是 top-level `analysis_info`
> - completed 與否都可以吐visionA 只在 completed 後才查到 metadata但即使 in_progress 帶上也無害visionA 全 optional。建議**有就吐、沒有回 null**,最簡單。
> - 這個 helper 同時被 GET /:id 與 GET 列表(`listJobsHandler`)共用,改一處兩邊都生效。
---
## 6. 測試建議(轉檔端怎麼驗)
### 6.1 端到端驗(最直接)
1. 跑一個完整轉檔 jobonnx → bie → nef等到 `status: completed`
2. `GET /api/v1/jobs/{id}`,確認 response **top-level 有 `analysis_info`**,且 `batch_size`/`channels`/`height`/`width`(或 `input_shape`)有實際數值(不是 null / 0
3. 確認數值與 bie worker log 印的維度一致core.py L44-47 讀到的值)
### 6.2 單元 / 整合驗(對齊既有 test 風格)
- `jobService.advanceJob` test`analysisInfo``getJob` 後確認 `job.analysis_info` 有寫進去;不傳 → `job.analysis_info` 維持 undefined
- `serializeJobForResponse` testjob record 有 `analysis_info` → output 有 top-level `analysis_info`;沒有 → output 為 `null`
- 既有 `getJobs.integration.test.js` 可加一個 casejob record 帶 analysis_info → GET response 帶 analysis_info
### 6.3 回歸(不要弄壞既有)
- onnx / nef stage 的 done event **不帶** analysis_info → `advanceJob` 不應因 undefined 參數報錯(`if (analysisInfo && ...)` guard
- 既有 job record`analysis_info` 欄位)→ GET response 的 `analysis_info``null`,不報錯
- `failJob` 路徑不受影響fail 不帶 analysis_info
---
## 7. 跨 repo 對接點(給轉檔端參考)
visionA 端讀取位置(**轉檔端不用改,僅供對照確認 schema 對齊**
| 項目 | 位置 |
|------|------|
| visionA unmarshal type | `visionA-backend/internal/conversion/converter_client.go``converterJobJSON.AnalysisInfo`L961/ `converterAnalysisInfoJSON`L968-976 |
| visionA 對映邏輯 | 同檔 `toInputShape()`L985-998+ `parseConverterJob`L1037-1042 |
| visionA 既有對齊測試 | `visionA-backend/internal/conversion/converter_client_test.go`L1343-14824 個 case明確 input_shape / 四維後備 / 部分維度防呆 / 缺 analysis_info |
| visionA struct tag權威 schema | `input_shape` / `batch_size` / `channels` / `height` / `width` / `classes` / `framework`converter_client.go L969-975 |
**驗證對齊的最快方法**:把 §3.1 的 JSON 丟進 visionA 的 `parseConverterJob`(或對照 `converter_client_test.go` L1361-1372 的 test fixture確認 `cj.InputShape == [1,3,224,224]`。轉檔端 GET response 只要長得跟那個 test fixture 一樣,就一定對得上。
---
## 8. 額外發現 / 注意事項
1. **bie worker 多一個 `input_name` 欄位**core.py L88visionA 不讀往上傳無害drop 也無害。
2. **bie worker 假設輸入是 4 維 NCHW**core.py L44-47 直接 index `dim[0..3]`):對非 4 維模型(如某些 NLP / 1D 模型)會 IndexError。這是**既有行為、不在本次交接範圍**但若未來要支援非影像模型core.py 這段要加防呆。提醒一下、不必現在處理。
3. **classes / framework 目前 bie worker 沒產**core.py 的 analysis_info 只有 input_name + 四維,沒有 `classes` / `framework`。visionA 這兩個欄位全 optional轉檔端要不要補由你決定。若不補visionA 對應欄位留空,不影響 input_shape 顯示。
4. **done event 的 at-least-once 語意**doneListener 的 ACK 在 try 內advanceJob throw 時不 ACK → 重投遞)。`advanceJob` 寫 analysis_info 是冪等的(同一份 record 重寫同樣的 analysis_info重投遞不會出問題符合既有設計。
5. **stage 順序**pipeline 是 `onnx → bie → nef`jobService.js L46 `STAGES`。analysis_info 在 bie stage 產出bie 完成時走的是 `advanceJob`(推進到 nef而非 COMPLETED branch所以**一定要在 advanceJob 寫**,不能只在 COMPLETED 那段寫,否則會漏。

View 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 CenterMCOpenIddict**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`
MCOpenIddict**不接受 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 真檔(約 800KBpromote 卡的純粹是 OAuth 換 token 那一步 |
**唯一變因就是 client 認證的送法Basic header vs form body。**
---
## 4. 要改的位置與改法
### 檔案
`apps/task-scheduler/src/auth/oauthClient.js`
### 當前 code 位置(已確認,行號為現況)
1. **`buildBasicAuthHeader()`**lines 9195— 產生 `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 299309
```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 1924目前寫死「使用 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 specRFC 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 1924說明改用 `client_secret_post`,否則註解與實作不一致,未來容易被誤改回 Basic。
---
## 附錄:與我描述略有出入的實際 code 結構(供對方校正)
- `buildBasicAuthHeader()` 實際在 **lines 9195**(描述為 ~9194
- 認證 header 實際送出處在 **lines 305309headers 物件)/ 316321fetch**`Authorization` 設定在 **line 308**(描述為 ~300308大致吻合
- **額外發現**body 除了 `grant_type` / `scope`,還帶了 `audience: config.faaAudience`line 302。改成 form body 時,是在這個**既有 body** 上新增 `client_id` / `client_secret`,不是憑空新建 body。
- 檔頭 lines 1924 的 design 註解明文寫「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 當理由 —— 這是當初的設計決策,改 code 時要連這段註解一起改掉。

View File

@ -0,0 +1,189 @@
# 交接文件MC id_token 缺 email claim導致 visionA 建 user 500
## 作者Architect AgentvisionA 端)
## 對象Member Center 團隊
## 狀態:待 MC 修復
## 最後更新2026-06-26
---
## 1. 一句話總結
**MCMember 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 Identity8.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 grantline 73-83沿用既有 principal若首次發 token 沒加 email、refresh 出來的也不會有。方案 (a) 因為在 factory 層處理refresh 重新驗證時也會走到(取決於 OpenIddict refresh 流程),較不易漏;建議優先 (a)。
### 5.3 建議一併加 `email_verified` claim
`email_verified` 是 OIDC 標準 claimboolean值 = `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 26switch 比對的 `OpenIddictConstants.Claims.Email` 為**同一常數**,確保 destination 路由能命中(這點目前 code 已一致,沿用同一常數即可)。

View File

@ -0,0 +1,221 @@
# visionA 部署指南
> 本檔聚焦「DB 接入版 backend 部署到 stage 並接真 PG/Redis」的端到端流程與煙測結果。
> stage 基礎設定host nginx、HTTPS termination、docker daemon 連線)另見同目錄 `stage-deployment-setup.md`
> Phase 0.6 交接見 `phase-0.6-handover.md`
## 1. 架構總覽DB 接入後)
```
公司 host nginx (HTTPS termination, LE 證書)
│ stage-9527.innovedus.com:9527
visiona container (image visiona:stage)
├─ 內層 nginx :80 ── 反代 ──► api-server :3721 (/api/*)
│ ── 反代 ──► frontend standalone (Next.js)
│ ── /healthz → return 200淺層 liveness不打 backend
│ ── /healthz/deep → 反代 api-server /healthz深層 readinessping PG+Redis見 §5/§9
├─ api-server :3721
│ ├─ Postgres pool ──► 192.168.0.130:5432/visiona真 PG
│ └─ Redis client ──► visiona-redis:6379/0真 Redis
└─ remote-proxy :3800/:3801tunnel
docker network: visiona-stage_default
├─ visiona 172.19.0.2
└─ visiona-redis 同網段hostname 解析靠這個 network
```
DB-on 啟用條件(`.env.stage` 已設):
- Postgres`VISIONA_DB_HOST` + `VISIONA_DB_USER` + `VISIONA_DB_NAME` 非空 → 自動建池 + auto-migrate + repository 切 Postgres。
- Redis`VISIONA_REDIS_HOST` 非空(無密碼也算啟用)→ userSession 切 Redis、cookie session 持久化。
- `VISIONA_DB_AUTO_MIGRATE` 預設 **true**`internal/config/load.go:109`)→ 啟動自動跑 `migrate up`
## 2. 前置需求
| 項目 | 內容 |
|------|------|
| docker daemon | stage host 130 開 `tcp://192.168.0.130:2375`公司內網直連VPN 大流量會卡,見 deploy-stage-v2.sh 註解) |
| `.env.stage` | 含 DB + Redis + OIDC + storage 等 env**git-ignored不進 repo**(已驗證 `git check-ignore` 命中) |
| 真 PG | 192.168.0.130:5432 db=`visiona` user=`vsausr` sslmode=`disable`schema 已 migrate 到 version 3 |
| 真 Redis | container `visiona-redis`network `visiona-stage_default`無密碼6379 |
| 部署腳本 | `scripts/deploy-stage-v2.sh`remote build 模式build 全在 stage daemon 跑) |
## 3. 部署步驟DB 接入版)
### 3.1 network 接 visiona-redis — 怎麼解決的(關鍵)
backend 用 hostname `visiona-redis` 連 Redis**必須跟 visiona-redis 同 docker network**否則解析失敗。
解決方式:**靠 compose project name 自動對上**,不需改 compose。
- `deploy-stage-v2.sh``-p visiona-stage` 起 compose → 預設 network 名為 `visiona-stage_default`
- `visiona-redis` 本來就在 `visiona-stage_default`Uplabel 顯示屬於同 stack
- 因此新 `visiona` container 起來自動落在同網段,`visiona-redis` hostname 直接可解。
驗證(部署前):
```bash
export DOCKER_HOST=tcp://192.168.0.130:2375
docker run --rm --network visiona-stage_default redis:alpine redis-cli -h visiona-redis ping # → PONG
docker run --rm --network visiona-stage_default postgres:16-alpine pg_isready -h 192.168.0.130 -p 5432 # → accepting connections
```
⚠️ 注意:`docker-compose.stage.yml` 沒有顯式宣告 external network。目前能對上是因為「compose 自建的 default network 名稱 = visiona-redis 所在的 network 名稱」這個巧合(同 project name。若未來 visiona-redis 改由別的 compose stack 管、或改 project name需在 compose 顯式 `networks:` 接 external `visiona-stage_default`。建議後續硬化(見 §10
### 3.2 部署指令
```bash
# 1. 先備份當前 image 供 rollback
export DOCKER_HOST=tcp://192.168.0.130:2375
CURID=$(docker inspect visiona --format '{{.Image}}' | cut -c8-19)
docker tag "$CURID" visiona:stage-rollback-pre-db
# 2. build + deploy main HEADremote build全在 stage daemon 跑)
DOCKER_HOST=tcp://192.168.0.130:2375 bash scripts/deploy-stage-v2.sh
```
部署會 `Recreate` visiona container覆蓋舊版。本次部署版本**main HEAD 4d0b870**DB 接入塊 0-5image tag `visiona:stage` + `visiona:stage-20260620-184814-4d0b870`
## 4. 啟動煙測本次實測2026-06-20
啟動 log 關鍵行(`.autoflow/07-delivery/logs/block6-container-startup-202606201848.log`
```
postgres pool initialized target=192.168.0.130:5432/visiona sslmode=disable max_conns=10 min_conns=2
migrate up: applied version=3
migrations applied target=192.168.0.130:5432/visiona
redis client initialized target=visiona-redis:6379/0 db=0
pairing/session token stores initialized backend=postgres
user session store initialized backend=redis
device repository initialized backend=postgres
device unpairer initialized backend=postgres-tx
model repository initialized backend=postgres
conversion service initialized converter_base_url=http://192.168.0.130:9501
file access (FAA download) initialized Phase 0.9 功能正常)
api-server listening addr=0.0.0.0:3721
```
無 fatal / panic。所有 6 個 store 都顯示 `backend=postgres` / `backend=redis`DB-on 模式生效)。
## 5. healthz 行為(重要)
> **2026-06-20 更新**§8 的 nginx /healthz 不反映 DB 健康問題**已修復並重部署**(見 §8、§9
> 現在 stage 有兩條對外健康路由:淺層 `/healthz`(存活)與深層 `/healthz/deep`readiness反映 PG+Redis
| 路徑 | 回應(健康時) | 說明 |
|------|------|------|
| `https://stage-9527.innovedus.com:9527/healthz`(公開,淺層) | `200 "ok"` | nginx 直接 return不打 backend、**不反映 DB**。用途liveness / 「nginx 活著」探測。 |
| `https://stage-9527.innovedus.com:9527/healthz/deep`(公開,深層)✅ 新增 | `200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"}` | nginx `proxy_pass` 到 backend `/healthz`**會 ping PG + Redis、down 回 503**。用途:**LB readiness**。 |
| backend `:3721/healthz`(容器內) | `200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"}` | 深層路由的後端來源,真正 ping PG + Redis。 |
backend healthz 實作(`internal/api/health.go`):每次呼叫 ping PG + Redis任一失敗回 **503**
### 5.1 503 fail-fast 實測(停 visiona-redis
log`.autoflow/07-delivery/logs/block6-healthz-503-202606201848.log`
```
docker stop visiona-redis
→ backend :3721/healthz HTTP 503 {"checks":{"postgres":"ok","redis":"down"},"status":"unavailable"}
docker start visiona-redis
→ backend :3721/healthz HTTP 200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"}
```
- Redis down → 正確偵測 `redis:down` 回 503PG 仍 `ok`
- visiona container **沒有因 Redis 中斷而 crash**fail-fast 只在啟動runtime 降級僅反映在 healthz
- Redis 恢復後 healthz 自動回 200。
## 6. 重啟資料持久化實測
log`.autoflow/07-delivery/logs/block6-persistence-202606201848.log`
```
插入 model row (name=block6-persist-...) 進 stage PG → rows_before_restart = 1
docker restart visiona
→ 啟動 log: "migrate up: no change (already at latest version)"migration 冪等)
→ backend healthz 200
查詢 model row → rows_after_restart = 1同 marker資料還在
(測試資料已 cleanup
```
證明真部署環境下 DB-on 的資料跨 container 重啟持久化。
## 7. 既有功能回歸
| 檢查 | 結果 |
|------|------|
| `GET /api/models`公開auth-gated | `401`api-server 活、DB-backed handler 可達,非 500/502 |
| `GET /`frontend | `200` |
| conversion service | 啟動 log 顯示 initializedconverter base url 正確) |
| FAA downloadPhase 0.9 | 啟動 log 顯示 initialized |
## 8. ✅ 已修復nginx /healthz 不反映 DB 健康(+ docker healthcheck false 444
**原問題**backend 的 DB-aware healthz會 ping PG/Redis、down 回 503 的 fail-fast 邏輯)**實作了但 nginx 攔截不轉發**。
- `docker/nginx.stage.conf` 兩個 `location = /healthz``return 200 "ok"`,不 `proxy_pass` 到 api-server。
- 結果:對外 `/healthz` 永遠 200即使 PG/Redis 掛了。backend 的 503 邏輯形同 dead code。
**併發現的第二個 bug本次一併修**default_server block 結尾用 **server-level `return 444;`**,在 nginx rewrite phase 會「先於」location 匹配短路掉所有請求 —— 包含本該回 200 的 `location = /healthz`。導致 docker healthcheck從 container 內打 `localhost/healthz`、來源 127.0.0.1)也被打成 **444**container 長期顯示 `(unhealthy)`FailingStreak 已累積到 96。原 deploy-stage-v2.sh:196 把這當成「Host 白名單造成的 false negative」實際成因是 `return 444` 的 phase 順序,不是 Host 白名單。
**修復內容2026-06-20commit 於 `docker/nginx.stage.conf`**
1. default_server 的 catch-all `return 444` 從 server-level 收進 `location / { return 444; }` —— 讓 exact-match `location = /healthz` 依 nginx location 優先序勝出。docker healthcheck 現回 200、container `healthy`
2. 公開 server block 新增 `location = /healthz/deep``proxy_pass http://visiona_api/healthz`(深層 readiness反映 PG+Redis
3. 保留淺層 `/healthz`return 200給 docker healthcheck / liveness。
**部署方式**:因 nginx config 在 image build 時 `COPY` 進去(非 bind mount改 source 後以 `deploy-stage-v2.sh` **rebuild + recreate** 持久化(`nginx -s reload` 只是 runtime 暫補、container 重建即失效)。重部署後驗證 baked config 與 source 一致。
## 9. healthz 接 load balancer最終建議 — 已實作)
**選項 A**liveness 淺 / readiness 深 分離,對齊 K8s 慣例)。已實作於 nginx
```nginx
# 公開 server blockserver_name stage-9527.innovedus.com
location = /healthz/deep {
access_log off;
proxy_pass http://visiona_api/healthz; # api-server :3721ping PG+Redis、down 回 503
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_connect_timeout 2s; proxy_read_timeout 2s; proxy_send_timeout 2s; # 探針不該 hang
proxy_no_cache 1; proxy_cache_bypass 1;
add_header Cache-Control "no-store" always;
}
```
**LB 設定建議**
- **health check path`/healthz/deep`** ← LB readiness 打這條
- 期望狀態碼200**503 → 拉出輪替**DB/Redis 掛時自動踢除實例)
- interval 1015s、timeout 5s、unhealthy threshold 連續 3 次(對齊 backend health.go 的 2s ping 逾時 + nginx 2s proxy 逾時)
- 淺層 `/healthz` 保留給 docker healthcheck / livenessDB 抖動時不會誤把實例標死)
**實測2026-06-20log`.autoflow/07-delivery/logs/healthz-deep-verify-*.log`**
```
PG+Redis 健康:
公開 /healthz → 200 "ok"
公開 /healthz/deep → 200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"}
docker healthcheck → healthystreak=0修復前 unhealthy streak=96
停 visiona-redis503 路徑):
公開 /healthz/deep → 503 {"checks":{"postgres":"ok","redis":"down"},"status":"unavailable"} ← LB 會踢
公開 /healthz → 200淺層不受影響liveness 不誤殺)
公開 /frontend → 200
起 visiona-redis恢復
公開 /healthz/deep → 200自動恢復
```
## 10. 後續硬化建議(非本次範圍)
1. ~~**§8 healthz 接 LB**:依 §9 選項 A 加 `/healthz/deep`。~~**已完成2026-06-20**,見 §8/§9。
2. **§3.1 network 顯式化**:在 `docker-compose.stage.yml` 顯式宣告 external network `visiona-stage_default`,去除「靠 project name 巧合對上」的隱性依賴。
3. **migration 與多副本**:目前單副本啟動跑 auto-migrate 沒問題;未來多副本需改 `VISIONA_DB_AUTO_MIGRATE=false` + 獨立 `cmd/migrate` 步驟,避免多實例同時 migrate。
## 11. Rollback
```bash
export DOCKER_HOST=tcp://192.168.0.130:2375
# 回到 DB 接入前的 Phase 0.9 image
docker tag visiona:stage-rollback-pre-db visiona:stage
bash scripts/deploy-stage-v2.sh --skip-build
```
注意rollback 到 DB 接入前版本後repository 回 in-memoryPG/Redis 資料保留但不被讀。)
## 12. 煙測 evidence log 清單
| log | 內容 |
|-----|------|
| `.autoflow/07-delivery/logs/block6-deploy-202606201848.log` | build + compose upexit 0 |
| `.autoflow/07-delivery/logs/block6-container-startup-202606201848.log` | 啟動 logDB/Redis/migration initialized |
| `.autoflow/07-delivery/logs/block6-healthz-503-202606201848.log` | 503 fail-fast 實測(停/起 redis |
| `.autoflow/07-delivery/logs/block6-persistence-202606201848.log` | 重啟資料持久化 + migration 冪等 |

View File

@ -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 logoutdiscovery 的
# end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client 未註冊
# post_logout_redirect_uri後端無法乾淨地 302 導向 MC end_session。
# 唯一瀏覽器可觸發的 MC 登出是 MemberCenter.Web:7880的 /account/logoutGET 即 302
# 設定本變數後POST /api/auth/logout 的 response 會多回 idp_logouturl + method=GET
# 由前端 navigatewindow.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=loginIdP 忽略既有 SSO session、要求重新輸入帳密。
# - false → 沿用 IdP 既有 session標準 SSO 體驗),為預設值。
# stage 想「每次點登入都重新輸入帳密」時設 trueprod 視 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 欄位

View File

@ -65,6 +65,12 @@ func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion.
FileSize: rec.FileSize,
FileChecksum: rec.FileChecksum,
TargetChip: rec.TargetChip,
// B4 模型 metadataoptional從 converter job analysis_info 串進來。
// 轉檔端尚未串好 analysis_info 時 rec.InputShape 等為零值nil / ""
// 這裡照樣賦值(賦 nil / "" 不影響建 modelDB 端寫 NULL / 空 INT[]。
InputShape: rec.InputShape,
Classes: rec.Classes,
Framework: rec.Framework,
Source: rec.Source, // 應為 "converted"
SourceJobID: rec.SourceJobID,
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1promote 寫入的 FAA object key
@ -119,6 +125,10 @@ func modelToRecord(m *model.Model) *conversion.ModelRecord {
FileSize: m.FileSize,
FileChecksum: m.FileChecksum,
TargetChip: m.TargetChip,
// B4 模型 metadataoptional冪等回傳時帶回保持 round-trip 一致。
InputShape: m.InputShape,
Classes: m.Classes,
Framework: m.Framework,
Source: m.Source,
SourceJobID: m.SourceJobID,
FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1

View File

@ -0,0 +1,372 @@
// conversion_adapters_test.go — conversionModelStoreAdapter 的單元測試。
//
// 聚焦 B4 metadataInputShape / Classes / Framework的雙向 round-trip
// - Saveconversion.ModelRecord → model.Model 時 metadata 要帶過去adapter 曾遺漏這段,
// 導致 flow.go 寫進 ModelRecord 的 input_shape 在 adapter 被吞掉、進不了 DB
// - modelToRecord透過 FindBySourceJobIDmodel.Model → conversion.ModelRecord 帶回
//
// 用 model.NewInMemoryRepository非 DB不需 dbtest tagDB 端真正落地由 PG repo 測試覆蓋。
//
// Phase 0.8 conversion B4 (見 .autoflow/04-architecture/conversion.md §2.5)
package main
import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/conversion"
"visiona-backend/internal/model"
"visiona-backend/internal/storage"
)
// TestConversionAdapter_Save_CarriesMetadataSave 應把 ModelRecord 的 B4 metadata
// 帶進底層 model.Modeladapter 曾漏掉 InputShape/Classes/Framework 的 wiring
func TestConversionAdapter_Save_CarriesMetadata(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
now := time.Now().UTC()
rec := &conversion.ModelRecord{
ID: "m-1",
OwnerUserID: "user-alice",
Name: "yolov5s_kl720",
StorageKey: "models/user-alice/m-1.nef",
FileSize: 12345,
TargetChip: "kl720",
InputShape: []int{1, 3, 224, 224},
Classes: []string{"face", "person"},
Framework: "onnx",
Source: "converted",
SourceJobID: "j-1",
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, adapter.Save(context.Background(), rec))
// 直接從底層 repo 撈回,驗 metadata 確實落進 model.Model
got, err := repo.Get(context.Background(), "m-1")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, []int{1, 3, 224, 224}, got.InputShape,
"adapter.Save 應把 ModelRecord.InputShape 帶進 model.ModelNCHW")
assert.Equal(t, []string{"face", "person"}, got.Classes)
assert.Equal(t, "onnx", got.Framework)
}
// TestConversionAdapter_Save_NilMetadata_OKmetadata 為零值(轉檔端尚未串好)→
// adapter.Save 照常成功、model.Model metadata 留零值、不報錯。
func TestConversionAdapter_Save_NilMetadata_OK(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
now := time.Now().UTC()
rec := &conversion.ModelRecord{
ID: "m-2",
OwnerUserID: "user-bob",
Name: "model_kl520",
StorageKey: "models/user-bob/m-2.nef",
TargetChip: "kl520",
Source: "converted",
SourceJobID: "j-2",
CreatedAt: now,
UpdatedAt: now,
// 刻意不設 InputShape / Classes / Framework
}
require.NoError(t, adapter.Save(context.Background(), rec))
got, err := repo.Get(context.Background(), "m-2")
require.NoError(t, err)
require.NotNil(t, got)
assert.Nil(t, got.InputShape, "缺 metadata → model.Model.InputShape 留 nil")
assert.Nil(t, got.Classes)
assert.Empty(t, got.Framework)
}
// TestConversionAdapter_FindBySourceJobID_RoundTripsMetadatamodelToRecord 應把
// model.Model 的 metadata 帶回 conversion.ModelRecord冪等回傳路徑保持一致
func TestConversionAdapter_FindBySourceJobID_RoundTripsMetadata(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
now := time.Now().UTC()
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
ID: "m-3",
OwnerUserID: "user-carol",
Name: "net_kl730",
StorageKey: "models/user-carol/m-3.nef",
TargetChip: "kl730",
InputShape: []int{1, 3, 640, 480},
Classes: []string{"dog"},
Framework: "tflite",
Source: "converted",
SourceJobID: "j-3",
CreatedAt: now,
UpdatedAt: now,
}))
rec, err := adapter.FindBySourceJobID(context.Background(), "user-carol", "j-3")
require.NoError(t, err)
require.NotNil(t, rec)
assert.Equal(t, []int{1, 3, 640, 480}, rec.InputShape,
"modelToRecord 應把 model.Model.InputShape 帶回 ModelRecord")
assert.Equal(t, []string{"dog"}, rec.Classes)
assert.Equal(t, "tflite", rec.Framework)
}
// ─────────────────────── 補強Save error / nil ───────────────────────
// Save(nil)error path —— nil record 應回明確 error不 panic、不寫入。
func TestConversionAdapter_Save_NilRecord_Errors(t *testing.T) {
t.Parallel()
adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository())
err := adapter.Save(context.Background(), nil)
require.Error(t, err, "Save(nil) 應回 error 而非 panic")
assert.Contains(t, err.Error(), "non-nil record")
}
// SaveUpdatedAt 為零值時 UploadedAt fallback 到 nowpromote 完即 ready
// 對齊 toModelResponseUploadedAt 非 nil → status "ready"。
func TestConversionAdapter_Save_ZeroUpdatedAt_SetsUploadedAtNow(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
before := time.Now().UTC()
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
ID: "m-zero",
OwnerUserID: "user-zero",
Name: "zero_updated_at",
StorageKey: "models/user-zero/m-zero.nef",
TargetChip: "kl520",
Source: "converted",
SourceJobID: "j-zero",
// 刻意不設 UpdatedAt零值
}))
after := time.Now().UTC()
got, err := repo.Get(context.Background(), "m-zero")
require.NoError(t, err)
require.NotNil(t, got.UploadedAt, "UpdatedAt 為零時 UploadedAt 應 fallback 到 now非 nil")
assert.False(t, got.UploadedAt.Before(before), "UploadedAt 應 >= 呼叫前時間")
assert.False(t, got.UploadedAt.After(after.Add(time.Second)), "UploadedAt 應 <= 呼叫後時間")
}
// SaveUpdatedAt 非零時 UploadedAt 沿用 rec.UpdatedAt不覆蓋成 now
func TestConversionAdapter_Save_NonZeroUpdatedAt_UsesIt(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
fixed := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
ID: "m-fixed",
OwnerUserID: "user-fixed",
Name: "fixed_updated_at",
StorageKey: "models/user-fixed/m-fixed.nef",
TargetChip: "kl730",
Source: "converted",
SourceJobID: "j-fixed",
UpdatedAt: fixed,
}))
got, err := repo.Get(context.Background(), "m-fixed")
require.NoError(t, err)
require.NotNil(t, got.UploadedAt)
assert.Equal(t, fixed, *got.UploadedAt, "UpdatedAt 非零時 UploadedAt 應沿用之")
}
// Save完整欄位 round-trip —— 不只 B4 metadata連 Description / FileChecksum /
// FAAObjectKey / Source / SourceJobID 都要正確帶進 model.Model。
func TestConversionAdapter_Save_AllFields_RoundTrip(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
now := time.Now().UTC()
rec := &conversion.ModelRecord{
ID: "m-full",
OwnerUserID: "user-full",
Name: "full_fields",
Description: "a full conversion record",
StorageKey: "models/user-full/m-full.nef",
FileSize: 987654,
FileChecksum: "sha256:deadbeef",
TargetChip: "kl720",
InputShape: []int{1, 3, 320, 320},
Classes: []string{"a", "b", "c"},
Framework: "onnx",
Source: "converted",
SourceJobID: "j-full",
FAAObjectKey: "faa/obj/full-key",
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, adapter.Save(context.Background(), rec))
got, err := repo.Get(context.Background(), "m-full")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "a full conversion record", got.Description)
assert.Equal(t, int64(987654), got.FileSize)
assert.Equal(t, "sha256:deadbeef", got.FileChecksum)
assert.Equal(t, "kl720", got.TargetChip)
assert.Equal(t, model.Source("converted"), got.Source)
assert.Equal(t, "j-full", got.SourceJobID)
assert.Equal(t, "faa/obj/full-key", got.FAAObjectKey)
assert.Equal(t, now, got.CreatedAt)
}
// ─────────────────────── 補強FindBySourceJobID edge ───────────────────────
// FindBySourceJobIDempty argsownerUserID 或 sourceJobID 為空)→ (nil, nil),不查 repo。
func TestConversionAdapter_FindBySourceJobID_EmptyArgs_ReturnsNil(t *testing.T) {
t.Parallel()
adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository())
cases := []struct{ owner, job string }{
{"", "j-1"},
{"user-1", ""},
{"", ""},
}
for _, tc := range cases {
rec, err := adapter.FindBySourceJobID(context.Background(), tc.owner, tc.job)
require.NoError(t, err)
assert.Nil(t, rec, "empty args 應回 (nil, nil)")
}
}
// FindBySourceJobIDrepo 有資料但無 match 的 sourceJobID → (nil, nil)。
func TestConversionAdapter_FindBySourceJobID_NoMatch_ReturnsNil(t *testing.T) {
t.Parallel()
repo := model.NewInMemoryRepository()
adapter := newConversionModelStoreAdapter(repo)
now := time.Now().UTC()
require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{
ID: "m-nm",
OwnerUserID: "user-nm",
Name: "no_match",
StorageKey: "models/user-nm/m-nm.nef",
TargetChip: "kl520",
Source: "converted",
SourceJobID: "j-exists",
CreatedAt: now,
UpdatedAt: now,
}))
// 對的 owner、不存在的 job
rec, err := adapter.FindBySourceJobID(context.Background(), "user-nm", "j-does-not-exist")
require.NoError(t, err)
assert.Nil(t, rec)
// 不同 owner、存在的 jobowner 過濾應使其找不到)
rec2, err := adapter.FindBySourceJobID(context.Background(), "user-other", "j-exists")
require.NoError(t, err)
assert.Nil(t, rec2, "不同 owner 不應 matchList 已用 OwnerUserID 過濾)")
}
// ─────────────────────── 補強GenerateID ───────────────────────
// GenerateID每次回傳唯一非空值uuid快速重複呼叫不應碰撞concurrency-ish / 唯一性)。
func TestConversionAdapter_GenerateID_Unique(t *testing.T) {
t.Parallel()
adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository())
seen := make(map[string]struct{})
for i := 0; i < 1000; i++ {
id := adapter.GenerateID()
require.NotEmpty(t, id, "GenerateID 不應回空字串")
_, dup := seen[id]
require.False(t, dup, "GenerateID 不應碰撞,重複值: %s", id)
seen[id] = struct{}{}
}
}
// ─────────────────────── 補強modelToRecord nil ───────────────────────
// modelToRecord(nil)edge —— nil model 應回 nil record不 panic。
func TestModelToRecord_Nil_ReturnsNil(t *testing.T) {
t.Parallel()
assert.Nil(t, modelToRecord(nil))
}
// ─────────────────────── 補強conversionStorageAdapter.Put ───────────────────────
// recordingStore 是只記下 Put 參數的 storage.Store fake驗 adapter 透傳)。
type recordingStore struct {
gotKey string
gotSize int64
gotMeta map[string]string
gotBody []byte
putErr error
}
func (r *recordingStore) Put(ctx context.Context, key string, rd io.Reader, size int64, meta map[string]string) error {
r.gotKey = key
r.gotSize = size
r.gotMeta = meta
if rd != nil {
r.gotBody, _ = io.ReadAll(rd)
}
return r.putErr
}
// 其餘 storage.Store 方法非本測試關注點回零值即可adapter 只用到 Put
func (r *recordingStore) Get(ctx context.Context, key string) (io.ReadCloser, *storage.Object, error) {
return nil, nil, storage.ErrNotFound
}
func (r *recordingStore) Stat(ctx context.Context, key string) (*storage.Object, error) {
return nil, storage.ErrNotFound
}
func (r *recordingStore) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
func (r *recordingStore) Delete(ctx context.Context, key string) error { return nil }
func (r *recordingStore) List(ctx context.Context, prefix string) ([]*storage.Object, error) {
return nil, nil
}
func (r *recordingStore) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
return "", nil
}
func (r *recordingStore) PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
return "", nil
}
// conversionStorageAdapter.Puthappy —— 透傳 key / size / meta / body 給底層 store。
func TestConversionStorageAdapter_Put_PassesThrough(t *testing.T) {
t.Parallel()
rs := &recordingStore{}
adapter := &conversionStorageAdapter{store: rs}
body := []byte("converted-nef-bytes")
meta := map[string]string{"source": "converter"}
err := adapter.Put(context.Background(), "models/u/x.nef", bytes.NewReader(body), int64(len(body)), meta)
require.NoError(t, err)
assert.Equal(t, "models/u/x.nef", rs.gotKey)
assert.Equal(t, int64(len(body)), rs.gotSize)
assert.Equal(t, meta, rs.gotMeta, "meta 應原樣透傳")
assert.Equal(t, body, rs.gotBody, "body 應原樣透傳")
}
// conversionStorageAdapter.Puterror path —— 底層 store 回錯時 adapter 應原樣回傳該 error。
func TestConversionStorageAdapter_Put_PropagatesError(t *testing.T) {
t.Parallel()
sentinel := errors.New("disk full")
rs := &recordingStore{putErr: sentinel}
adapter := &conversionStorageAdapter{store: rs}
err := adapter.Put(context.Background(), "k", bytes.NewReader([]byte("x")), 1, nil)
assert.ErrorIs(t, err, sentinel, "底層 Put error 應原樣往上傳")
}

View File

@ -0,0 +1,89 @@
// dbon_fk_fix_test.go — DB-on FK 收尾兩個問題的 integration 驗證in-memory wiring
//
// 不帶 build tag屬於預設 `go test ./...` 範圍。用 fixture 的 in-memory store + in-memory
// exchanger 走真實 OIDC login flow / pairing exchange flow驗證
// - 問題 #1OIDC callback 成功後 user 被 provision 進 UserStore。
// - 問題 #2pairing exchange 成功後自建一筆 device且該 device owner = 登入 user。
//
// DB-on 行為(真 FK / 交易)由 internal/user/postgres_store_db_test.go 與
// internal/api/pairing_exchange_db_test.go 的 dbtest 覆蓋(待 130 補跑)。
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
)
// TestDBOnFix_OIDCCallbackProvisionsUser 驗證問題 #1OIDC callback 成功後 user 進 UserStore。
func TestDBOnFix_OIDCCallbackProvisionsUser(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer f.Close()
// 登入前user 不存在
_, err := f.userStore.Get(context.Background(), "alice-sub")
require.Error(t, err, "登入前 user 不應存在")
// 走完整 OIDC login flowcallback 會 Upsert user
_ = f.AuthenticatedClient(t, "alice-sub", "alice@example.com")
// 登入後user 已被 provision
got, err := f.userStore.Get(context.Background(), "alice-sub")
require.NoError(t, err, "OIDC callback 後 user 應被 provision 進 UserStore")
assert.Equal(t, "alice-sub", got.ID)
assert.Equal(t, "alice@example.com", got.Email)
}
// TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2pairing exchange 後自建 deviceowner 對齊)。
func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer f.Close()
const sub = "bob-sub"
client := f.AuthenticatedClient(t, sub, "bob@example.com")
// 登入後但 exchange 前:該 user 名下無 device
before, err := f.deviceRepo.List(context.Background(), sub)
require.NoError(t, err)
require.Empty(t, before, "exchange 前不應有 device")
// 1) 產 pairing token
tokResp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil)
require.NoError(t, err)
defer tokResp.Body.Close()
require.Equal(t, http.StatusOK, tokResp.StatusCode)
var tokBody map[string]any
require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody))
pairingTok := tokBody["data"].(map[string]any)["token"].(string)
// 2) exchange不走 AuthMiddleware→ 應自建 device + 建 session token
reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok})
exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange",
"application/json", bytes.NewReader(reqBody))
require.NoError(t, err)
defer exchResp.Body.Close()
require.Equal(t, http.StatusOK, exchResp.StatusCode)
var exchBody map[string]any
require.NoError(t, json.NewDecoder(exchResp.Body).Decode(&exchBody))
sessionTok := exchBody["data"].(map[string]any)["session_token"].(string)
require.True(t, auth.IsValidSessionToken(sessionTok))
// 3) exchange 後:該 user 名下多了一筆自建 device
after, err := f.deviceRepo.List(context.Background(), sub)
require.NoError(t, err)
require.Len(t, after, 1, "exchange 應自建一筆 device")
assert.Equal(t, sub, after[0].OwnerUserID, "device owner 應為登入 user")
assert.NotNil(t, after[0].PairedAt, "自建 device 應設 paired_at")
}

View File

@ -0,0 +1,309 @@
//go:build dbtest
// e2e_dbon_test.go — 塊 6 完整版6.4 整鏈持久化 e2e + 6.5 DB-on 回歸驗證)。
//
// 這是「DB 接入」的最終 e2e 驗收:證明 6 個 store 都接上 Postgres / Redis 後,
//
// 1. 既有 e2e 路徑OIDC 登入 → model 上傳 → 列 → get/ownership在 DB-on 模式仍跑通;
// 2. **重啟pool 重建)後資料還在**持久化的核心承諾——in-memory 模式做不到);
// 3. **unpair device 後該 device 的 pairing + session token 真的被撤銷**(塊 5.2 cascade
// 的 e2e/store 層驗證,含直接查 DB 斷言 revoked_at / deleted_at
//
// build tag `dbtest`:需要 DockerPG 容器)。本機無 docker → Orchestrator 在 130 補跑:
//
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest -run 'DBOn' ./cmd/api-server/... -v
//
// 設計取捨(重要,與既有 in-memory e2e 的差異點,對應回報給 Orchestrator 的發現):
//
// - **owner_user_id FK**DB-on 下 models/devices/*_tokens 的 owner 是 `UUID NOT NULL
// REFERENCES users(id)`。AuthenticatedClient 的 OIDC sub 必須是合法 UUID且對應 users 列
// 必須先存在production OIDC callback 不 auto-provision users → 見回報的疑似 bug
// 故 DB-on e2e 一律用 uuid.NewString() 當 sub + 先 f.ensureUser(...)。
//
// - **pairing→exchange 整鏈走不到 HTTP**session_tokens.device_id 是 `UUID NOT NULL
// REFERENCES devices(id)`,但雛形 pairing exchange handler 傳的 info.DeviceID 為空
// pairing token 未綁 device→ 在 DB-on 下 Create session token 會因 NOT NULL/FK 失敗。
// 因此「unpair cascade」這條無法靠 HTTP exchange 製造已綁 device 的 session token改在
// store 層直接建user → device → 已綁 device 的 pairing token + session token再經
// DeviceUnpairer.Unpair 驗 cascade。這如實反映雛形現況且仍覆蓋塊 5.2 的 Postgres tx 路徑。
// (此差異已回報 OrchestratorHTTP exchange 在 DB-on 模式需綁 device_id 才能完整跑通。)
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/db"
"visiona-backend/internal/device"
)
// discardLoggerDBOn 回傳一個丟棄輸出的 logger避免測試噪音
func discardLoggerDBOn() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// noopLocal 是不需要 tunnel 的 e2e 用的空 local handler。
func noopLocal() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
}
// uploadModelDBOn 走完整兩階段上傳init → PUT → finalize回傳 model id。
// 對齊 b5_integration_test.go 的 TestB5_ModelUploadFlow但跑在 DB-on fixture 上。
func uploadModelDBOn(t *testing.T, f *dbOnFixture, client *http.Client, name string) string {
t.Helper()
// 1. init
initBody, _ := json.Marshal(map[string]any{
"name": name,
"file_size": 11,
"target_chip": "kl520",
})
initResp, err := client.Post(f.apiServer.URL+"/api/models/init", "application/json", bytes.NewReader(initBody))
require.NoError(t, err)
require.Equal(t, http.StatusOK, initResp.StatusCode, "init 應成功DB-on")
var initRespBody map[string]any
require.NoError(t, json.NewDecoder(initResp.Body).Decode(&initRespBody))
initResp.Body.Close()
initData := initRespBody["data"].(map[string]any)
modelID := initData["model_id"].(string)
uploadURL := initData["upload_url"].(string)
require.NotEmpty(t, modelID)
require.NotEmpty(t, uploadURL)
// 2. PUT 檔案HMAC presigned不走 auth
payload := []byte("hello world") // 11 bytes 對上 file_size
putReq, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(payload))
require.NoError(t, err)
putReq.ContentLength = int64(len(payload))
putResp, err := http.DefaultClient.Do(putReq)
require.NoError(t, err)
defer putResp.Body.Close()
require.Equal(t, http.StatusNoContent, putResp.StatusCode, "PUT 應 204")
// 3. finalize
finResp, err := client.Post(f.apiServer.URL+"/api/models/"+modelID+"/finalize", "application/json", nil)
require.NoError(t, err)
defer finResp.Body.Close()
require.Equal(t, http.StatusOK, finResp.StatusCode, "finalize 應成功DB-on")
var fbody map[string]any
require.NoError(t, json.NewDecoder(finResp.Body).Decode(&fbody))
fdata := fbody["data"].(map[string]any)
assert.Equal(t, "ready", fdata["status"])
assert.Equal(t, modelID, fdata["id"])
return modelID
}
// listModelIDsDBOn 打 GET /api/models 回 model id 集合。
func listModelIDsDBOn(t *testing.T, f *dbOnFixture, client *http.Client) map[string]bool {
t.Helper()
resp, err := client.Get(f.apiServer.URL + "/api/models")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
out := map[string]bool{}
for _, raw := range body["data"].([]any) {
m := raw.(map[string]any)
out[m["id"].(string)] = true
}
return out
}
// TestDBOn_ModelUploadFlow_E2E 驗證 OIDC 登入 → model 上傳 → 列 → get/ownership 在 DB-on 跑通。
//
// 這是把 b5 in-memory 上傳 e2e「平移到 Postgres」的回歸驗證證明改注入 PG model repo +
// PG token store + Redis session 後handler 一行不改仍端對端跑通。
func TestDBOn_ModelUploadFlow_E2E(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
sub := uuid.NewString()
email := "dbon-upload@visiona.local"
f.ensureUser(t, sub, email)
client := f.AuthenticatedClient(t, sub, email)
modelID := uploadModelDBOn(t, f, client, "YOLOv5 DB-on")
// GET /api/models — 看得到剛上傳的
ids := listModelIDsDBOn(t, f, client)
assert.True(t, ids[modelID], "list 應含剛上傳的 modelDB-on")
// GET /api/models/:id — owner 取得成功
getResp, err := client.Get(f.apiServer.URL + "/api/models/" + modelID)
require.NoError(t, err)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode, "owner get 應 200")
// 直接查 DB 斷言 row 真的落地(不只是 handler 記憶體)。
var cnt int
require.NoError(t, f.pool.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM models WHERE id = $1 AND owner_user_id = $2 AND deleted_at IS NULL`,
modelID, sub).Scan(&cnt))
assert.Equal(t, 1, cnt, "model row 應已落 Postgres")
// 另一個 user 不該看到owner 隔離 + FK 也成立)。
otherSub := uuid.NewString()
f.ensureUser(t, otherSub, "other-dbon@visiona.local")
otherClient := f.AuthenticatedClient(t, otherSub, "other-dbon@visiona.local")
otherGet, err := otherClient.Get(f.apiServer.URL + "/api/models/" + modelID)
require.NoError(t, err)
defer otherGet.Body.Close()
assert.Equal(t, http.StatusForbidden, otherGet.StatusCode, "非 owner 取他人 model 應 403")
}
// TestDBOn_PersistAcrossRestart 是塊 6 的核心承諾:**重啟後資料還在**。
//
// 重啟模擬:上傳 model 後,關掉 fixture 當前 pool、用同一個 tdb.Cfg 新建第二個 pool
// (指向同一個 Postgres 容器),證明資料落在 DB 而非 process 記憶體。
// 第二個 pool 直接查 + 經一個「新 router新 PG repo」打 API 都看得到。
//
// 對照in-memory 模式NewInMemoryRepository一旦 process 重啟資料即消失,
// 本 test 在 in-memory fixture 下會失敗——正是 DB 接入要解決的問題。
func TestDBOn_PersistAcrossRestart(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
sub := uuid.NewString()
email := "dbon-restart@visiona.local"
f.ensureUser(t, sub, email)
client := f.AuthenticatedClient(t, sub, email)
modelID := uploadModelDBOn(t, f, client, "Persist-Across-Restart")
// === 模擬「process 重啟」:丟棄當前 pool新建一個指向同一容器的 pool ===
f.pool.Close() // 關掉 fixture 原 pool模擬舊 process 退出)
newPool, err := db.NewPool(context.Background(), f.tdb.Cfg, discardLoggerDBOn())
require.NoError(t, err, "重啟後新建 pool")
defer newPool.Close()
// 新 pool 直接查:資料還在。
var cnt int
require.NoError(t, newPool.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM models WHERE id = $1 AND deleted_at IS NULL`, modelID).Scan(&cnt))
assert.Equal(t, 1, cnt, "重啟(新 pool後 model row 仍在 Postgres —— 持久化成立")
// 進一步owner_user_id 也仍正確FK + 欄位都落地)。
var owner string
require.NoError(t, newPool.Pool().QueryRow(context.Background(),
`SELECT owner_user_id::text FROM models WHERE id = $1`, modelID).Scan(&owner))
assert.Equal(t, sub, owner, "重啟後 model.owner_user_id 仍為原 user")
}
// TestDBOn_SeedNotDuplicatedAcrossRestart 驗證 seed 行為在「重啟(重跑 ensure」下不重複。
//
// seed.go 用 `INSERT ... ON CONFLICT (id) DO NOTHING` upsert demo user多次呼叫不應產生
// 重複 user 列。本 test 直接重複 upsert 同一 demo user 兩次(模擬重啟兩次都跑 seed
// 斷言 users 仍只有一筆——對應「重啟不重複 seed」的承諾塊 6 完整版要點)。
func TestDBOn_SeedNotDuplicatedAcrossRestart(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
const demoUser = "00000000-0000-0000-0000-0000000000d3" // 對齊 seed.go demoSeedUserID
ctx := context.Background()
upsert := func() {
_, err := f.pool.Pool().Exec(ctx,
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
demoUser, "demo@visiona.local", "Demo User (seeded)")
require.NoError(t, err)
}
upsert() // 第一次「啟動 seed」
upsert() // 第二次「重啟 seed」
var cnt int
require.NoError(t, f.pool.Pool().QueryRow(ctx,
`SELECT count(*) FROM users WHERE id = $1`, demoUser).Scan(&cnt))
assert.Equal(t, 1, cnt, "重複 seed重啟後 demo user 仍只有一筆 —— ON CONFLICT 生效")
}
// TestDBOn_UnpairCascade_RevokesTokens 是塊 5.2 cascade 的 e2e/store 層驗證Postgres tx 路徑)。
//
// 建一條完整資料鏈user → device → 已綁該 device 的 pairing tokenMarkUsed 綁 device+
// 已綁該 device 的 session token再經 DeviceUnpairer.Unpair= main.go 在 DB-on 注入的
// pgDeviceUnpairer單一交易內軟刪 device + 撤兩張 token最後**直接查 DB** 斷言:
// - device.deleted_at 非 NULL軟刪
// - 該 device 的 pairing token revoked_at 非 NULL
// - 該 device 的 session token revoked_at 非 NULL
// - Unpair 回報撤銷數正確
//
// 為什麼不走 HTTP /unpairunpair handler 需要 AuthMiddleware + device 屬於登入者;本 test 聚焦
// cascade 的「真撤 token」核心跨三張表的交易一致性直接調 unpairer 並查 DB 最直接、最少噪音。
// HTTP /unpair 的 happy/404 路徑由 in-memory 的 devices unpair test 覆蓋(行為一致)。
func TestDBOn_UnpairCascade_RevokesTokens(t *testing.T) {
f := setupFixtureDBOn(t, noopLocal())
defer f.Close()
ctx := context.Background()
tdb := f.tdb
// 1. user + device用 testsupport fixture 直接寫,滿足 FK
userID := tdb.InsertUser(t, uuid.NewString(), "unpair-dbon@visiona.local")
deviceID := tdb.InsertDevice(t, uuid.NewString(), userID)
// 2. pairing token綁 deviceCreate → MarkUsed(deviceID) 寫入 device_id。
pairingStore := auth.NewPostgresPairingStore(f.pool.Pool())
ptPlain, _, err := pairingStore.Create(ctx, userID, time.Hour)
require.NoError(t, err, "create pairing token")
require.NoError(t, pairingStore.MarkUsed(ctx, ptPlain, deviceID), "mark pairing token used + bind device")
// 3. session token綁 devicedevice_id NOT NULL FK這裡傳真 device → 寫入成功。
sessionStore := auth.NewPostgresSessionTokenStore(f.pool.Pool())
stPlain, _, err := sessionStore.Create(ctx, userID, deviceID, "", 90*24*time.Hour)
require.NoError(t, err, "create session token bound to device")
// 前置斷言:兩 token 撤銷前都「未撤銷」。
require.Equal(t, 0, countRevokedTokensDBOn(t, f, "pairing_tokens", deviceID), "撤銷前 pairing 應為 0 revoked")
require.Equal(t, 0, countRevokedTokensDBOn(t, f, "session_tokens", deviceID), "撤銷前 session 應為 0 revoked")
// 4. UnpairPostgres txdevice 軟刪 + cascade 撤兩張 token
unpairer := api.NewPostgresDeviceUnpairer(f.pool.Pool(),
device.NewPostgresRepository(f.pool.Pool()),
pairingStore, sessionStore, discardLoggerDBOn())
res, err := unpairer.Unpair(ctx, deviceID)
require.NoError(t, err, "unpair 應成功")
assert.Equal(t, 1, res.PairingRevoked, "應撤 1 個 pairing token")
assert.Equal(t, 1, res.SessionRevoked, "應撤 1 個 session token")
// 5. 直接查 DB 斷言 cascade 落地
// 5a. device 軟刪
var deletedAt *time.Time
require.NoError(t, f.pool.Pool().QueryRow(ctx,
`SELECT deleted_at FROM devices WHERE id = $1`, deviceID).Scan(&deletedAt))
assert.NotNil(t, deletedAt, "device 應被軟刪deleted_at 非 NULL")
// 5b. 兩張 token 都撤銷
assert.Equal(t, 1, countRevokedTokensDBOn(t, f, "pairing_tokens", deviceID), "pairing token 應 revoked")
assert.Equal(t, 1, countRevokedTokensDBOn(t, f, "session_tokens", deviceID), "session token 應 revoked")
// 5c. 撤銷後 session token Get 應失敗(不可再用 → tunnel 連不上)。
_, getErr := sessionStore.Get(ctx, stPlain)
assert.ErrorIs(t, getErr, auth.ErrTokenRevoked, "撤銷後 session token Get 應回 ErrTokenRevoked")
}
// countRevokedTokensDBOn 查某 device 名下已撤銷的 token 數pairing_tokens / session_tokens 通用)。
func countRevokedTokensDBOn(t *testing.T, f *dbOnFixture, table, deviceID string) int {
t.Helper()
var n int
// table 來自測試常數(非使用者輸入),直接內插安全。
require.NoError(t, f.pool.Pool().QueryRow(context.Background(),
`SELECT count(*) FROM `+table+` WHERE device_id = $1 AND revoked_at IS NOT NULL`,
deviceID).Scan(&n))
return n
}

View File

@ -0,0 +1,211 @@
//go:build dbtest
// fixture_dbon_test.go — 塊 6 完整版6.5 e2e 回歸setupFixture 的 DB-on 變體。
//
// 既有 integration / e2eintegration_test.go / b5_integration_test.go / e2e_full_flow_test.go /
// oidc_e2e_test.go / conversion_e2e_test.go全部走 setupFixtureWithMaxUpload 的「6 個 in-memory
// store」版本——那是 local-dev fallback 行為。DB 接入塊 05 把 6 個 store 都接上 Postgres / Redis
// 後,必須證明「同一套 handler、改注入 PG/Redis storee2e 仍跑得通」,這就是本檔提供的
// setupFixtureDBOn。
//
// build tag `dbtest`:本檔與 e2e_dbon_test.go 只在 `-tags=dbtest` 時編譯(需要 Docker daemon
// 本機無 docker → 由 Orchestrator 在 130 docker host 補跑)。預設 `go test ./...` 不觸碰,
// 不影響既有 in-memory 模式的 e2e兩種模式並存
//
// 注入對照(對齊 cmd/api-server/main.go 的 dbPool != nil / redisClient != nil 分支):
//
// store in-memory既有 fixture DB-on本檔
// ----------------- --------------------------------------- -------------------------------------------
// PairingStore auth.NewInMemoryPairingStore() auth.NewPostgresPairingStore(pool)
// SessionTokenStore auth.NewInMemorySessionTokenStore() auth.NewPostgresSessionTokenStore(pool)
// DeviceRepo device.NewInMemoryRepository() device.NewPostgresRepository(pool)
// ModelRepo model.NewInMemoryRepository() model.NewPostgresRepository(pool)
// DeviceUnpairer api.NewInMemoryDeviceUnpairer(...) api.NewPostgresDeviceUnpairer(pool,...)
// userSession usersession.NewInMemoryStore() usersession.NewRedisUserSessionStore(miniredis)
// tunnel session session.NewInMemoryStore() session.NewInMemoryStore()(不持久化、不受 DB 影響)
//
// 為什麼 userSession 用 miniredis 而非真 Redisminiredis 在 process 內跑、無 Docker / 無網路需求,
// 與真 RedisUserSessionStore 走同一條 go-redis client 介面main.go 也是注入 go-redis client
// 真 Redis130行為已由 internal/usersession/redis_integration_test.go 另行覆蓋;此處 e2e 聚焦
// 「PG 持久化 + Redis session 都接上後整鏈跑通」miniredis 足夠且更乾淨。
package main
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
goredis "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/converter"
"visiona-backend/internal/db"
"visiona-backend/internal/db/testsupport"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/oidctest"
"visiona-backend/internal/relay"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
)
// dbOnFixture 是 setupFixtureDBOn 的回傳:在 testFixture 之上多帶 DB-on 專屬的引用,
// 讓「重啟資料還在」「直接查 DB 斷言 cascade」這類 test 能拿到 pool / cfg。
type dbOnFixture struct {
*testFixture
// tdb 是 testcontainers 起的一次性 Postgres含已 migrate 的 schema
// 容器 / pool 的 teardown 由 SetupTestDB 的 t.Cleanup 管,呼叫端不需手動關。
tdb *testsupport.TestDB
// pool 是本次 fixture wire 進所有 PG store 的連線池(= tdb.Pool
// 重啟模擬時,會新建第二個 pool 指向同一個容器(同 tdb.Cfg來證明資料落在 DB 不在 process 記憶體。
pool *db.Pool
// miniRedis 是 user session 用的 in-process Redis。
miniRedis *miniredis.Miniredis
}
// ensureUser 為一個 OIDC sub必須是合法 UUIDupsert 一筆 users 列。
//
// 為什麼需要DB-on 模式下 models.owner_user_id / devices.owner_user_id / *_tokens.user_id 都是
// `UUID NOT NULL REFERENCES users(id)`。AuthenticatedClient 的 sub 是任意字串、且 production 的
// OIDC callback 不會 auto-provision users 列(見回報給 Orchestrator 的疑似 production bug
// 因此 DB-on e2e 在「登入後打需要 owner 的 API」前必須先確保對應 users 列存在,否則 FK 失敗。
//
// 回傳傳入的 sub方便 inline 使用)。
func (f *dbOnFixture) ensureUser(t *testing.T, sub, email string) string {
t.Helper()
_, err := f.pool.Pool().Exec(context.Background(),
`INSERT INTO users (id, email) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
sub, email)
require.NoError(t, err, "ensure users row for OIDC sub %s", sub)
return sub
}
// setupFixtureDBOn 啟動與 setupFixtureWithMaxUpload 相同的 5 段架構,但 6 個持久化 store
// 改注入 Postgrestestcontainers+ Redisminiredis
//
// 與 in-memory 版的唯一差別在 store 注入handler / router / OIDC / tunnel 完全相同,
// 證明「介面不變、只換後端」的塊 05 設計目標在 e2e 層成立。
func setupFixtureDBOn(t *testing.T, localHandler http.Handler) *dbOnFixture {
t.Helper()
// 0. 起 Postgres 容器(已 migrate+ in-process Redis。
tdb := testsupport.SetupTestDB(t)
mr := miniredis.RunT(t)
// 1. fake local-tool
localBackend := httptest.NewServer(localHandler)
// 2. remote-proxytunnel session store 仍 in-memoryyamux 連線註冊表,本來就不落 DB
tunnelStore := session.NewInMemoryStore()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
relaySrv := relay.NewServer(tunnelStore, logger, relay.Options{KeepAliveInterval: 500 * time.Millisecond})
internalSrv := relay.NewInternalServer(tunnelStore, logger)
tunnelMux := http.NewServeMux()
tunnelMux.HandleFunc("/tunnel/connect", relaySrv.HandleTunnelConnect)
tunnelTS := httptest.NewServer(tunnelMux)
internalMux := http.NewServeMux()
internalSrv.Routes(internalMux)
internalTS := httptest.NewServer(internalMux)
// 3. api-server proxy/forwarder指向 internalTS
proxyClient := session.NewHTTPProxyClient(internalTS.URL, logger)
forwarder := session.NewForwarder(internalTS.URL, logger)
sessionStore := session.NewProxyClientStore(proxyClient, forwarder)
storeDir := t.TempDir()
lazy := &lazyHandler{}
apiTS := httptest.NewServer(lazy)
storeStore, err := storage.NewLocalFSStore(storeDir, apiTS.URL+"/storage", "test-secret")
require.NoError(t, err)
// 4. fake OIDC + OIDC client同 in-memory 版)
fakeOIDC := oidctest.NewServer(t,
oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret),
)
callbackURL := apiTS.URL + "/api/auth/callback"
oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 5*time.Second)
oidcProvider, err := oidc.NewProvider(oidcCtx, oidc.ProviderConfig{
IssuerURL: fakeOIDC.URL,
ClientID: fakeOIDC.ClientID,
ClientSecret: fakeOIDC.ClientSecret,
RedirectURL: callbackURL,
})
oidcCancel()
require.NoError(t, err, "fixture(dbon): OIDC provider init failed")
// ===== DB-on store 注入(對齊 main.go dbPool/redisClient 非 nil 分支) =====
pool, err := db.NewPool(context.Background(), tdb.Cfg, logger)
require.NoError(t, err, "fixture(dbon): second pool for fixture")
t.Cleanup(pool.Close)
pgPairingStore := auth.NewPostgresPairingStore(pool.Pool())
pgSessionTokenStore := auth.NewPostgresSessionTokenStore(pool.Pool())
pgDeviceRepo := device.NewPostgresRepository(pool.Pool())
pgModelRepo := model.NewPostgresRepository(pool.Pool())
pgUnpairer := api.NewPostgresDeviceUnpairer(pool.Pool(), pgDeviceRepo, pgPairingStore, pgSessionTokenStore, logger)
// user sessionRedisminiredis。TTL 對齊 main.go 預設量級。
redisClient := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
userSessionStore := usersession.NewRedisUserSessionStore(redisClient, 24*time.Hour, 168*time.Hour)
sessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{
Name: "visiona_session",
Path: "/",
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
SigningKey: []byte(fixtureSessionSecret),
})
router := api.NewRouter(api.Deps{
Logger: logger,
PairingStore: pgPairingStore,
SessionTokenStore: pgSessionTokenStore,
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: pgDeviceRepo,
ModelRepo: pgModelRepo,
DeviceUnpairer: pgUnpairer, // 塊 5.2 cascadePostgres tx
Storage: storeStore,
Converter: converter.NewStubClient(),
RelayPublicURL: tunnelTS.URL,
OIDCProvider: oidcProvider,
SessionManager: sessionMgr,
OIDCPostLoginURL: apiTS.URL,
})
lazy.Set(router)
tf := &testFixture{
apiServer: apiTS,
internalSrv: internalTS,
tunnelSrv: tunnelTS,
localBackend: localBackend,
store: tunnelStore,
forwarder: forwarder,
fakeOIDC: fakeOIDC,
pairingStore: nil, // DB-on 模式不暴露 in-memory pairingStore需要直接查時走 f.pool
sessionMgr: sessionMgr,
router: router,
}
return &dbOnFixture{
testFixture: tf,
tdb: tdb,
pool: pool,
miniRedis: mr,
}
}

View File

@ -51,6 +51,7 @@ import (
"visiona-backend/internal/relay"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/user"
"visiona-backend/internal/usersession"
"visiona-backend/internal/wsconn"
)
@ -107,6 +108,12 @@ type testFixture struct {
pairingStore *auth.InMemoryPairingStore
sessionMgr *usersession.Manager
// userStore / deviceRepo 暴露給 DB-on FK 收尾的 test
// - userStore驗證 OIDC callback 後 user 被 provision問題 #1
// - deviceRepo驗證 pairing exchange 後自建 device問題 #2
userStore *user.InMemoryStore
deviceRepo *device.InMemoryRepository
// router 暴露 *gin.Engine 給需要列出所有 route 的 test
// (目前用於 all_endpoints_require_auth_test.go — Phase 0.7 security regression
router *gin.Engine
@ -211,14 +218,17 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
})
pairingStore := auth.NewInMemoryPairingStore()
sessionTokenStore := auth.NewInMemorySessionTokenStore()
deviceRepo := device.NewInMemoryRepository()
userStore := user.NewInMemoryStore()
router := api.NewRouter(api.Deps{
Logger: logger,
PairingStore: pairingStore,
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
SessionTokenStore: sessionTokenStore,
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: device.NewInMemoryRepository(),
DeviceRepo: deviceRepo,
ModelRepo: model.NewInMemoryRepository(),
Storage: storeStore,
Converter: converter.NewStubClient(),
@ -227,6 +237,10 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
MaxUploadSizeMB: maxUploadMB,
RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL
// DB-on FK 收尾in-memory user store問題 #1+ in-memory pairing exchanger問題 #2
UserStore: userStore,
PairingExchanger: api.NewInMemoryPairingExchanger(deviceRepo, sessionTokenStore),
// OIDC wiringOB5
OIDCProvider: oidcProvider,
SessionManager: sessionMgr,
@ -244,6 +258,8 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
fakeOIDC: fakeOIDC,
pairingStore: pairingStore,
sessionMgr: sessionMgr,
userStore: userStore,
deviceRepo: deviceRepo,
router: router,
}
}

View File

@ -23,7 +23,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
@ -38,6 +37,7 @@ import (
"visiona-backend/internal/oidc"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/user"
"visiona-backend/internal/usersession"
)
@ -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,
@ -265,6 +268,33 @@ func main() {
log.Info("model repository initialized", "backend", "in-memory")
}
// userDB-on FK 收尾(問題 #1— OIDC callback provision users 列用。
// dbPool != nil 時切到 PostgresStore否則 in-memorylocal-dev fallback
// D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID。callback 一行不需改地切換。
var userStore user.Store
if dbPool != nil {
userStore = user.NewPostgresStore(dbPool.Pool())
log.Info("user store initialized", "backend", "postgres")
} else {
userStore = user.NewInMemoryStore()
log.Info("user store initialized", "backend", "in-memory")
}
// pairing exchangerDB-on FK 收尾(問題 #2— exchange 時自建 device + 建 session token。
// - Postgresdb.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 注入 Deps.PairingExchangerexchange handler 偵測非 nil 即走「自建 device」路徑。
var pairingExchanger api.PairingExchanger
if dbPool != nil {
pairingExchanger = api.NewPostgresPairingExchanger(
dbPool.Pool(), pgDeviceRepo, pgSessionTokenStore, log)
log.Info("pairing exchanger initialized", "backend", "postgres-tx")
} else {
pairingExchanger = api.NewInMemoryPairingExchanger(
deviceRepo, memSessionTokenStore)
log.Info("pairing exchanger initialized", "backend", "in-memory")
}
// ===== ConverterstubPhase 2 才實作) =====
converterClient := converter.NewStubClient()
@ -356,13 +386,9 @@ func main() {
// ===== Seed demo data可選 =====
if cfg.Server.SeedDemoData {
// dbPool 非 nil 時seed 的 model 走 Postgres塊 1seedDemoData 內部會先 ensure
// demo user 列並改用合法 UUID owner / idnil 時維持雛形 in-memory 行為。
var seedPool *pgxpool.Pool
if dbPool != nil {
seedPool = dbPool.Pool()
}
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, seedPool, log); err != nil {
// dbPool 非 nil 時seed 的 model/device 走 Postgres塊 1+seedDemoData 內部會先透過
// userStore.Upsert ensure demo user 列並改用合法 UUID owner / idnil 時維持雛形 in-memory 行為。
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, userStore, cfg.Auth.StaticUserID, dbPool != nil, log); err != nil {
log.Warn("seed demo data failed", "error", err)
}
}
@ -392,6 +418,7 @@ func main() {
DeviceRepo: deviceRepo,
ModelRepo: modelRepo,
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpairPostgres tx / in-memory 依序)
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2exchange 自建 device + session token
Storage: storageStore,
Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501
@ -409,6 +436,8 @@ func main() {
OIDCProvider: oidcProvider,
SessionManager: userSessionMgr,
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空logout 只清本地、不連動 MC向下相容
UserStore: userStore, // DB-on FK 收尾 #1OIDC callback provision users 列
})
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))

View File

@ -6,11 +6,11 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/auth"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/user"
)
// demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。
@ -34,15 +34,20 @@ const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3"
// - 重複呼叫會產生重複資料本函式只該被呼叫一次main 已保證)
// - **不要**在生產環境啟用此 flag
//
// dbPool 非 nil 表 model repo 已切到 Postgres塊 1此時 seed 的 model 必須用合法 UUID
// 與已存在的 owner_user_idUUID + FK故先 upsert demo user、改用 demoSeedUserID。
// dbPool 為 nilin-memory fallback時行為與雛形完全相同。
// dbBacked 為 true 表 repository 已切到 Postgres塊 1+):此時 seed 的 model/device 必須用
// 合法 UUID 與已存在的 owner_user_idUUID + FK故先 upsert demo user、改用 demoSeedUserID。
// dbBacked 為 falsein-memory fallback時行為與雛形完全相同。
//
// demo user 透過注入的 userStore.Upsert 落地DB-on 時 = PostgresStore滿足 models/devices/
// pairing_tokens 的 owner_user_id FK——與 OIDC callback 走同一條 provision 路徑,避免 seed
// 自己手拼 INSERT users 與 store 行為不一致(問題 #1 的 seed 對齊)。
func seedDemoData(
devRepo device.Repository,
mdlRepo model.Repository,
pairings auth.PairingStore,
userStore user.Store,
userID string,
dbPool *pgxpool.Pool,
dbBacked bool,
log *slog.Logger,
) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -61,17 +66,19 @@ func seedDemoData(
pairingOwnerID := userID
modelID := "demo-model-" + uuid.NewString()[:8]
deviceID := "demo-device-" + uuid.NewString()[:8]
if dbPool != nil {
if dbBacked {
modelOwnerID = demoSeedUserID
deviceOwnerID = demoSeedUserID
pairingOwnerID = demoSeedUserID
modelID = uuid.NewString()
deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串
// 先確保 owner user 存在(滿足 models / devices owner_user_id FK
if _, err := dbPool.Exec(ctx,
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil {
// 走 userStore.UpsertDB-on = PostgresStore與 OIDC callback 同一條 provision 路徑。
if err := userStore.Upsert(ctx, &user.User{
ID: demoSeedUserID,
Email: "demo@visiona.local",
Name: "Demo User (seeded)",
}); err != nil {
log.Warn("seed: ensure demo user failed", "error", err)
}
}

View File

@ -0,0 +1,110 @@
//go:build dbtest
// seed_db_test.go — seedDemoData 的 DB-backeddbBacked=true整合測試。
//
// Owner: testing agent補測任務 — cmd/api-server 可測部分DB 分支)
//
// build tag `dbtest`:只在 `go test -tags=dbtest` 時編譯/執行(需 Docker
// 本機無 Docker由 Orchestrator 在 130 跑:
//
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest ./cmd/api-server/ -run TestSeedDemoData_DBBacked -count=1
//
// 對齊 seed.go docstring 的 DB-backed 契約:
// - 先 upsert demoSeedUserID 對應 users 列(滿足 models/devices/pairing_tokens 的 owner FK
// - model/device id 改用合法 UUID非雛形字串owner 統一為 demoSeedUserID。
// - 全部落 Postgres用真 PG repository非 in-memory
package main
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/auth"
"visiona-backend/internal/db/testsupport"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/user"
)
// TestSeedDemoData_DBBacked_UpsertsUserAndSeedsAllDB-backed seed 應
// 1. upsert demoSeedUserID 進 users滿足 FK
// 2. 用 demoSeedUserID 當 owner 建 device + model + pairing token全落 PG
func TestSeedDemoData_DBBacked_UpsertsUserAndSeedsAll(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
tdb.Truncate(t, "pairing_tokens", "session_tokens", "models", "devices", "users")
ctx := context.Background()
devRepo := device.NewPostgresRepository(tdb.Pool)
mdlRepo := model.NewPostgresRepository(tdb.Pool)
pairings := auth.NewPostgresPairingStore(tdb.Pool)
userStore := user.NewPostgresStore(tdb.Pool)
// userID 傳入雛形 StaticUserID非 UUID—— DB-backed 分支應忽略它、改用 demoSeedUserID。
require.NoError(t, seedDemoData(
devRepo, mdlRepo, pairings, userStore,
"demo-user", true /* dbBacked */, discardLog(),
))
// 1. demo user 已 upsertowner FK 前提)
u, err := userStore.Get(ctx, demoSeedUserID)
require.NoError(t, err, "DB-backed seed 應 upsert demoSeedUserID")
assert.Equal(t, "demo@visiona.local", u.Email)
// 2. deviceowner = demoSeedUserID非傳入的 demo-user
devs, err := devRepo.List(ctx, demoSeedUserID)
require.NoError(t, err)
require.Len(t, devs, 1, "DB-backed seed 應建 1 個 device 掛在 demoSeedUserID")
assert.Equal(t, demoSeedUserID, devs[0].OwnerUserID)
assert.Equal(t, "kl520", devs[0].DeviceType)
// 註device 確實掛在 demoSeedUserID上方斷言已證且 owner_user_id 為 UUID 欄位,
// 傳入的非-UUID "demo-user" 在型別上不可能成為任何 device 的 owner故無需另查查了反而會被 PG reject
// 3. modelowner = demoSeedUserID
mdls, err := mdlRepo.List(ctx, model.ListFilter{OwnerUserID: demoSeedUserID})
require.NoError(t, err)
require.Len(t, mdls, 1, "DB-backed seed 應建 1 個 model 掛在 demoSeedUserID")
assert.Equal(t, demoSeedUserID, mdls[0].OwnerUserID)
assert.Equal(t, model.SourceUploaded, mdls[0].Source)
// 4. pairing tokenowner = demoSeedUserID
tokens, err := pairings.List(ctx, demoSeedUserID)
require.NoError(t, err)
require.Len(t, tokens, 1, "DB-backed seed 應建 1 個 pairing token 掛在 demoSeedUserID")
assert.Equal(t, demoSeedUserID, tokens[0].UserID)
}
// TestSeedDemoData_DBBacked_GeneratesUUIDIDsDB-backed 分支的 model/device id 必須是合法 UUID
// schema 的 id 欄位為 UUID + FK雛形的 "demo-model-xxx" 字串會違反型別)。
func TestSeedDemoData_DBBacked_GeneratesUUIDIDs(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
tdb.Truncate(t, "pairing_tokens", "session_tokens", "models", "devices", "users")
ctx := context.Background()
devRepo := device.NewPostgresRepository(tdb.Pool)
mdlRepo := model.NewPostgresRepository(tdb.Pool)
pairings := auth.NewPostgresPairingStore(tdb.Pool)
userStore := user.NewPostgresStore(tdb.Pool)
require.NoError(t, seedDemoData(
devRepo, mdlRepo, pairings, userStore,
"demo-user", true, discardLog(),
))
devs, err := devRepo.List(ctx, demoSeedUserID)
require.NoError(t, err)
require.Len(t, devs, 1)
assert.Regexp(t,
`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`,
devs[0].ID, "DB-backed device id 應為合法 UUID")
mdls, err := mdlRepo.List(ctx, model.ListFilter{OwnerUserID: demoSeedUserID})
require.NoError(t, err)
require.Len(t, mdls, 1)
assert.Regexp(t,
`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`,
mdls[0].ID, "DB-backed model id 應為合法 UUID")
}

View File

@ -0,0 +1,165 @@
// seed_test.go — seedDemoData 的 in-memorydbBacked=falseunit test。
//
// Owner: testing agent補測任務 — cmd/api-server 可測部分)
//
// 對象seedDemoData。in-memory 分支不需 DB純 unit。DB-backeddbBacked=true分支
// 需真 Postgres → 拆到 seed_db_test.go//go:build dbtest130 docker 跑)。
//
// 行為對齊 seed.go docstring
// - dbBacked=false 用傳入 userIDStaticUserID 概念)當 ownermodel/device id 帶隨機後綴。
// - 不 upsert demo userin-memory 不需 FK
// - seedDemoData 本身「每次呼叫都產新資料」id 帶 uuid 後綴 / pairing 每次新建)——
// 「重啟不重複 seed」是 main.go 只呼叫一次保證的,不是本函式的冪等性;故本檔不誤測冪等。
package main
import (
"context"
"io"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/auth"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
"visiona-backend/internal/user"
)
// discardLog 丟棄 seed 的 log 噪音。
func discardLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// seedInMemoryDeps 建立一組全 in-memory 的 seed 依賴。
type seedDeps struct {
devRepo *device.InMemoryRepository
mdlRepo *model.InMemoryRepository
pairings *auth.InMemoryPairingStore
userStore *user.InMemoryStore
}
func newSeedDeps() seedDeps {
return seedDeps{
devRepo: device.NewInMemoryRepository(),
mdlRepo: model.NewInMemoryRepository(),
pairings: auth.NewInMemoryPairingStore(),
userStore: user.NewInMemoryStore(),
}
}
// happyin-memory seed 應建出 1 device + 1 model + 1 pairing token全掛在傳入的 userID 下。
func TestSeedDemoData_InMemory_SeedsAllEntities(t *testing.T) {
t.Parallel()
d := newSeedDeps()
const uid = "demo-user" // 雛形 StaticUserID 概念in-memory 不需是合法 UUID
require.NoError(t, seedDemoData(
d.devRepo, d.mdlRepo, d.pairings, d.userStore,
uid, false /* dbBacked */, discardLog(),
))
ctx := context.Background()
// deviceowner = uid
devs, err := d.devRepo.List(ctx, uid)
require.NoError(t, err)
require.Len(t, devs, 1, "in-memory seed 應建 1 個 demo device")
assert.Equal(t, uid, devs[0].OwnerUserID)
assert.Equal(t, "kl520", devs[0].DeviceType)
assert.Equal(t, device.RemoteStatusOffline, devs[0].RemoteStatus)
// modelowner = uidStorageKey 用 owner 拼路徑
mdls, err := d.mdlRepo.List(ctx, model.ListFilter{OwnerUserID: uid})
require.NoError(t, err)
require.Len(t, mdls, 1, "in-memory seed 應建 1 個 demo model")
assert.Equal(t, uid, mdls[0].OwnerUserID)
assert.Equal(t, "kl520", mdls[0].TargetChip)
assert.Equal(t, model.SourceUploaded, mdls[0].Source)
assert.Equal(t, "models/"+uid+"/demo.nef", mdls[0].StorageKey)
require.NotNil(t, mdls[0].UploadedAt, "seed model 應設 UploadedAtready")
// pairing tokenowner = uid
tokens, err := d.pairings.List(ctx, uid)
require.NoError(t, err)
require.Len(t, tokens, 1, "in-memory seed 應建 1 個 demo pairing token")
assert.Equal(t, uid, tokens[0].UserID)
}
// in-memory 不 upsert demo user不需 FKuserStore 應維持空demoSeedUserID 不存在)。
func TestSeedDemoData_InMemory_DoesNotUpsertDemoUser(t *testing.T) {
t.Parallel()
d := newSeedDeps()
require.NoError(t, seedDemoData(
d.devRepo, d.mdlRepo, d.pairings, d.userStore,
"demo-user", false, discardLog(),
))
// dbBacked=false 不應走 userStore.Upsert(demoSeedUserID)
_, err := d.userStore.Get(context.Background(), demoSeedUserID)
assert.Error(t, err, "in-memory seed 不應 upsert demoSeedUserID無 FK 需求)")
}
// boundary / concurrency-ishseedDemoData 每次呼叫產生獨立資料id 帶隨機後綴)——
// 連呼叫兩次同一 userID 應累積 2 device / 2 model / 2 token對齊 docstring本函式非冪等
// 「不重複 seed」由 main 只呼叫一次保證)。此測試把該行為釘住,避免有人誤改成「靜默去重」。
func TestSeedDemoData_InMemory_CalledTwice_Accumulates(t *testing.T) {
t.Parallel()
d := newSeedDeps()
const uid = "demo-user"
log := discardLog()
require.NoError(t, seedDemoData(d.devRepo, d.mdlRepo, d.pairings, d.userStore, uid, false, log))
require.NoError(t, seedDemoData(d.devRepo, d.mdlRepo, d.pairings, d.userStore, uid, false, log))
ctx := context.Background()
devs, err := d.devRepo.List(ctx, uid)
require.NoError(t, err)
assert.Len(t, devs, 2, "兩次呼叫應產生 2 個 deviceid 帶隨機後綴、不去重)")
mdls, err := d.mdlRepo.List(ctx, model.ListFilter{OwnerUserID: uid})
require.NoError(t, err)
assert.Len(t, mdls, 2, "兩次呼叫應產生 2 個 model")
tokens, err := d.pairings.List(ctx, uid)
require.NoError(t, err)
assert.Len(t, tokens, 2, "兩次呼叫應產生 2 個 pairing token")
}
// error path底層 repo Save 失敗時 seedDemoData 只 log warning、不回 error、不中斷fail-soft
// 用一個 Save 永遠失敗的 device repo 驗「seed 失敗不阻擋啟動」契約。
func TestSeedDemoData_InMemory_DeviceSaveFails_StillReturnsNil(t *testing.T) {
t.Parallel()
d := newSeedDeps()
err := seedDemoData(
failingDeviceRepo{}, // device.Save 永遠失敗
d.mdlRepo, d.pairings, d.userStore,
"demo-user", false, discardLog(),
)
require.NoError(t, err, "device save 失敗時 seedDemoData 仍應回 nilfail-soft不阻擋啟動")
// model / pairing 仍應照常建立device 失敗不影響後續步驟)
mdls, err := d.mdlRepo.List(context.Background(), model.ListFilter{OwnerUserID: "demo-user"})
require.NoError(t, err)
assert.Len(t, mdls, 1, "device save 失敗不應中斷 model 建立")
}
// failingDeviceRepo 是 device.Save 永遠回錯的 device.Repository其餘方法委派給空行為
type failingDeviceRepo struct{}
func (failingDeviceRepo) Get(ctx context.Context, id string) (*device.Device, error) {
return nil, device.ErrNotFound
}
func (failingDeviceRepo) GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error) {
return nil, device.ErrNotFound
}
func (failingDeviceRepo) List(ctx context.Context, ownerUserID string) ([]*device.Device, error) {
return nil, nil
}
func (failingDeviceRepo) Save(ctx context.Context, dev *device.Device) error {
return assert.AnError
}
func (failingDeviceRepo) Delete(ctx context.Context, id string) error { return nil }

View File

@ -31,6 +31,7 @@ import (
"visiona-backend/internal/oidc"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/user"
"visiona-backend/internal/usersession"
)
@ -62,6 +63,26 @@ type Deps struct {
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
OIDCPostLoginURL string
// OIDCLogoutURL 是「讓使用者連帶登出 IdPMember Centersession」的入口 URL。
//
// 背景MC 不支援標準 OIDC RP-initiated logout唯一瀏覽器可觸發的登出是
// MemberCenter.Web:7880的 /account/logoutGET 即 302。詳見 config.OIDCConfig.LogoutURL。
//
// 非空時logout handleroidc_auth.go會在 LogoutResponse 多回 idp_logout 欄位,
// 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。
// 對齊 cfg.OIDC.LogoutURLenv VISIONA_OIDC_LOGOUT_URL
OIDCLogoutURL string
// UserStore 在 OIDC callback 驗 id_token 成功後 provisionupsert一筆 users 列
// DB-on FK 收尾,問題 #1。D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID
//
// 為何必須DB-on有 FK真人登入後任何帶 owner_user_id FK 的寫入(上傳 model、配對、
// 發 pairing token都需要 users 表已有對應列;不 upsert → FK violation。
//
// 為 nil 時 callback 略過 upsert最小骨架 / 純 OIDC unit test 不強制注入);
// main.go 依 dbPool 注入 PostgresStore 或 InMemoryStore兩模式都會 upsert 對齊行為。
UserStore user.Store
SessionStore session.Store
Forwarder *session.Forwarder
@ -119,6 +140,14 @@ type Deps struct {
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
RelayPublicURL string
// PairingExchanger 在 pairing exchange 時自建一筆 device 並建綁該 device 的 session token
// DB-on FK 收尾,問題 #2session_tokens.device_id NOT NULL FK但雛形流程從不建 device
// - Postgres用 db.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 為 nil 時 exchange handler fallback 到舊行為(直接用 info.DeviceID 建 session token
// 不自建 device——僅 DB-off 雛形相容用。main.go 依 dbPool 擇一注入。
PairingExchanger PairingExchanger
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴DB 接入塊 5.4)。
// 由 main.go 注入 db.Pool / db.RedisClient皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
// /healthz 略過不檢查in-memory 模式維持「process 活著就 ok」

View File

@ -99,3 +99,28 @@ func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) {
WriteError(c, cls.status, cls.code, cls.message, nil)
}
// WriteStorageError 把一個 storage物件儲存操作錯誤映射成對外 API 錯誤並寫回 response
// 同時把 raw error 進 server log含 request_id
//
// 動機(塊 5 Minor-1presigned URL / Stat 等 storage 操作失敗時handler 過去把
// err.Error() 直接串進 response message可能洩漏 storage 後端細節bucket / endpoint /
// 內部路徑 / 簽章參數)給前端。本函式比照 WriteDBError對外只給穩定 code + 通用 message
// raw error 只進 server log。
//
// 對外一律 500 INTERNAL_ERRORstorage 後端不可用 / 設定錯誤對前端而言都是「伺服器端問題」,
// 不細分(與 WriteDBError 的 503 區隔開——DB 是 fail-fast 拉機制的依賴、storage 不是)。
// 呼叫端應先處理 storage sentinel如 storage.ErrNotFound回對應的 4xx再把剩下的
// 「真 storage 錯誤」交給本函式。
//
// op 是操作描述(如 "presigned put url" / "stat object"),只進 log、不對外。
func WriteStorageError(c *gin.Context, log *slog.Logger, op string, err error) {
logOrDefault(log).Error("storage operation failed",
"op", op,
"error", err,
"http_status", http.StatusInternalServerError,
"code", ErrCodeInternalError,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "internal error", nil)
}

View File

@ -0,0 +1,172 @@
// errors_test.go — errors.go 3 個純 helperWriteError / WriteSuccess / WriteNotImplemented的 unit test。
//
// Owner: testing agent補測任務 — internal/api 弱處純函式)
//
// 這 3 個函式直接寫 gin.Context 的 JSON responseenvelope 形狀對齊 api-spec.md §11
// 用 httptest.NewRecorder() + gin.CreateTestContext 驅動,驗 status code + envelope 結構 +
// request_id 帶入 + details / nil 邊界。
//
// 注意errors_db.go 的 DB 錯誤映射另由 errors_db_test.go 覆蓋,本檔不重複。
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestCtx 建立一個帶 ResponseRecorder 的 gin.ContextreqID 非空時塞入 request_id。
func newTestCtx(reqID string) (*gin.Context, *httptest.ResponseRecorder) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
if reqID != "" {
c.Set(ctxKeyRequestID, reqID)
}
return c, w
}
// decodeErrorBody 把 recorder body 解成 ErrorBody。
func decodeErrorBody(t *testing.T, w *httptest.ResponseRecorder) ErrorBody {
t.Helper()
var body ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
return body
}
// ───────────────────────── WriteError ─────────────────────────
// happy寫一個帶 request_id 的 404 錯誤envelope 形狀正確。
func TestWriteError_Happy_WithRequestID(t *testing.T) {
c, w := newTestCtx("req-123")
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
body := decodeErrorBody(t, w)
assert.False(t, body.Success, "錯誤 envelope success 必為 false")
require.NotNil(t, body.Error)
assert.Equal(t, ErrCodeNotFound, body.Error.Code)
assert.Equal(t, "device not found", body.Error.Message)
assert.Equal(t, "req-123", body.Error.RequestID, "WriteError 應自動帶上 request_id")
assert.Nil(t, body.Error.Details, "未傳 details 時應為 nilomitempty")
}
// boundary帶 detailsvalidation 細節details 應出現在 envelope。
func TestWriteError_WithDetails(t *testing.T) {
c, w := newTestCtx("req-val")
details := []FieldError{
{Field: "name", Message: "required"},
{Field: "target_chip", Message: "unsupported"},
}
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "validation failed", details)
assert.Equal(t, http.StatusBadRequest, w.Code)
body := decodeErrorBody(t, w)
require.NotNil(t, body.Error)
require.Len(t, body.Error.Details, 2)
assert.Equal(t, "name", body.Error.Details[0].Field)
assert.Equal(t, "required", body.Error.Details[0].Message)
assert.Equal(t, "target_chip", body.Error.Details[1].Field)
}
// empty / edge沒有 request_id 時request_id 欄位被 omitempty 省略(不應出現空字串 key 影響 client
func TestWriteError_NoRequestID_OmitsField(t *testing.T) {
c, w := newTestCtx("") // 不塞 request_id
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "boom", nil)
assert.Equal(t, http.StatusInternalServerError, w.Code)
// request_id 為 omitempty空值時不應出現在 JSON
raw := w.Body.String()
assert.NotContains(t, raw, "request_id", "request_id 為空時應被 omitempty 省略")
body := decodeErrorBody(t, w)
require.NotNil(t, body.Error)
assert.Equal(t, ErrCodeInternalError, body.Error.Code)
assert.Empty(t, body.Error.RequestID)
}
// edge空 details slicelen 0→ omitempty 省略(不應出現 "details": [])。
func TestWriteError_EmptyDetailsSlice_Omitted(t *testing.T) {
c, w := newTestCtx("req-x")
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "msg", []FieldError{})
raw := w.Body.String()
assert.NotContains(t, raw, "details", "空 details slice 應被 omitempty 省略")
}
// ───────────────────────── WriteSuccess ─────────────────────────
// happy寫一個 200 成功回應data 帶入。
func TestWriteSuccess_Happy(t *testing.T) {
c, w := newTestCtx("")
payload := map[string]any{"id": "m-1", "name": "yolo"}
WriteSuccess(c, http.StatusOK, payload)
assert.Equal(t, http.StatusOK, w.Code)
var body SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.True(t, body.Success, "成功 envelope success 必為 true")
data, ok := body.Data.(map[string]any)
require.True(t, ok)
assert.Equal(t, "m-1", data["id"])
assert.Equal(t, "yolo", data["name"])
}
// edgedata 為 nil如 201 Created 無 body→ data 被 omitempty 省略,但 success 仍在。
func TestWriteSuccess_NilData_OmitsData(t *testing.T) {
c, w := newTestCtx("")
WriteSuccess(c, http.StatusCreated, nil)
assert.Equal(t, http.StatusCreated, w.Code)
raw := w.Body.String()
assert.Contains(t, raw, `"success":true`)
assert.NotContains(t, raw, "data", "nil data 應被 omitempty 省略")
}
// boundarynon-201 status 也能用WriteSuccess 不限定 status
func TestWriteSuccess_CustomStatus(t *testing.T) {
c, w := newTestCtx("")
WriteSuccess(c, http.StatusAccepted, map[string]string{"state": "queued"})
assert.Equal(t, http.StatusAccepted, w.Code)
var body SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.True(t, body.Success)
}
// ───────────────────────── WriteNotImplemented ─────────────────────────
// happy回 501 NOT_IMPLEMENTEDhint 帶進 message。
func TestWriteNotImplemented_Happy(t *testing.T) {
c, w := newTestCtx("req-ni")
WriteNotImplemented(c, "clusters endpoint pending B5")
assert.Equal(t, http.StatusNotImplemented, w.Code)
body := decodeErrorBody(t, w)
require.NotNil(t, body.Error)
assert.False(t, body.Success)
assert.Equal(t, ErrCodeNotImplemented, body.Error.Code)
assert.Equal(t, "clusters endpoint pending B5", body.Error.Message)
assert.Equal(t, "req-ni", body.Error.RequestID)
}
// edge空 hint 也成立message 為空字串、code 仍為 NOT_IMPLEMENTED
func TestWriteNotImplemented_EmptyHint(t *testing.T) {
c, w := newTestCtx("")
WriteNotImplemented(c, "")
assert.Equal(t, http.StatusNotImplemented, w.Code)
body := decodeErrorBody(t, w)
require.NotNil(t, body.Error)
assert.Equal(t, ErrCodeNotImplemented, body.Error.Code)
assert.Empty(t, body.Error.Message)
}

View File

@ -60,6 +60,13 @@ type ModelResponse struct {
FileSize int64 `json:"file_size"`
Source string `json:"source"`
Status string `json:"status"` // "pending" / "ready"
// 模型 metadataB4 鏈路最後一個序列化點)。
// snake_case 對齊本 DTO 既有慣例target_chip / file_size / created_at
// 前端 model-store normalize 雙吃 snake/camelinput_shape(snake) 在其讀取範圍內。
// omitempty上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。
InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
@ -79,6 +86,9 @@ func toModelResponse(m *model.Model) ModelResponse {
FileSize: m.FileSize,
Source: m.Source,
Status: status,
InputShape: m.InputShape,
Classes: m.Classes,
Framework: m.Framework,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
UploadedAt: m.UploadedAt,
@ -244,8 +254,8 @@ func modelsInitUploadHandler(deps Deps) gin.HandlerFunc {
// 產 presigned PUT URL
uploadURL, err := deps.Storage.PresignedPutURL(ctx, storageKey, modelUploadURLTTL)
if err != nil {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"presigned url failed: "+err.Error(), nil)
// 塊 5 Minor-1storage 錯誤統一映射,不把 raw errbucket/endpoint 等)洩漏給前端。
WriteStorageError(c, deps.Logger, "presigned put url", err)
return
}
@ -343,8 +353,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc {
"file not uploaded yet; PUT to upload_url first", nil)
return
}
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"stat storage failed: "+statErr.Error(), nil)
// 塊 5 Minor-1storage 錯誤統一映射,不把 raw errbucket/endpoint 等)洩漏給前端。
WriteStorageError(c, deps.Logger, "stat object", statErr)
return
}
// Size 驗證(雛形只比對 sizePhase 1 加 checksum

View File

@ -183,6 +183,122 @@ func TestModelsDelete_NotOwner(t *testing.T) {
assert.Equal(t, http.StatusForbidden, w.Code)
}
// TestModelsGet_HTTPResponseCarriesMetadata 驗證 B4 metadata 鏈路的最後序列化點:
// GET /api/models/:id 的 HTTP JSON response 真的含 input_shape / classes / framework。
//
// 這是先前測試的盲區——舊測試只測到 model.Model 落地dbtest為止、沒測最外層 HTTP
// JSON。此測試直接 call handler → 解析 response JSON → 斷言三欄都在裡面且值正確。
func TestModelsGet_HTTPResponseCarriesMetadata(t *testing.T) {
r, repo, _ := newModelsFixture(t)
now := time.Now().UTC()
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "mdl-meta",
OwnerUserID: "demo-user",
Name: "metamodel",
Source: model.SourceConverted,
InputShape: []int{1, 3, 224, 224},
Classes: []string{"cat", "dog"},
Framework: "onnx",
CreatedAt: now,
UpdatedAt: now,
}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-meta", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data, ok := sb.Data.(map[string]any)
require.True(t, ok, "data should be an object")
// input_shape 必須在最外層 HTTP JSON 裡snake_case前端 normalize 讀得到)。
rawShape, present := data["input_shape"]
require.True(t, present, "input_shape must be present in HTTP JSON response; body=%s", w.Body.String())
shape, ok := rawShape.([]any)
require.True(t, ok)
require.Len(t, shape, 4)
assert.EqualValues(t, 1, shape[0])
assert.EqualValues(t, 3, shape[1])
assert.EqualValues(t, 224, shape[2])
assert.EqualValues(t, 224, shape[3])
// classes / framework 同樣要帶到。
classes, ok := data["classes"].([]any)
require.True(t, ok, "classes must be present; body=%s", w.Body.String())
require.Len(t, classes, 2)
assert.Equal(t, "cat", classes[0])
assert.Equal(t, "dog", classes[1])
assert.Equal(t, "onnx", data["framework"])
}
// TestModelsGet_OmitsMetadataWhenAbsent 驗證 omitempty無 metadata 的 model如上傳類 /
// 舊 model回應不含 input_shape / classes / framework不破壞既有回應結構。
func TestModelsGet_OmitsMetadataWhenAbsent(t *testing.T) {
r, repo, _ := newModelsFixture(t)
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "mdl-nometa",
OwnerUserID: "demo-user",
Name: "plain",
Source: model.SourceUploaded,
}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-nometa", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data, ok := sb.Data.(map[string]any)
require.True(t, ok)
_, hasShape := data["input_shape"]
_, hasClasses := data["classes"]
_, hasFramework := data["framework"]
assert.False(t, hasShape, "input_shape should be omitted when empty")
assert.False(t, hasClasses, "classes should be omitted when empty")
assert.False(t, hasFramework, "framework should be omitted when empty")
}
// TestModelsList_HTTPResponseCarriesMetadata 驗證 list endpointGET /api/models
// HTTP JSON 也帶 input_shape列表頁雖非 B4 主顯示處,仍確認鏈路一致)。
func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) {
r, repo, _ := newModelsFixture(t)
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "mdl-list-meta",
OwnerUserID: "demo-user",
Name: "listed",
Source: model.SourceConverted,
InputShape: []int{1, 28, 28},
Framework: "tflite",
}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
require.Len(t, arr, 1)
first := arr[0].(map[string]any)
rawShape, present := first["input_shape"]
require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String())
shape, ok := rawShape.([]any)
require.True(t, ok)
require.Len(t, shape, 3)
assert.EqualValues(t, 1, shape[0])
assert.EqualValues(t, 28, shape[1])
assert.EqualValues(t, 28, shape[2])
assert.Equal(t, "tflite", first["framework"])
}
// TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。
func TestModelsList_FiltersByOwner(t *testing.T) {
r, repo, _ := newModelsFixture(t)

View File

@ -32,6 +32,7 @@ import (
"github.com/gin-gonic/gin"
"visiona-backend/internal/oidc"
"visiona-backend/internal/user"
)
// oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。
@ -39,6 +40,42 @@ import (
// 又不會讓 caller 端等到 default HTTP server timeout。
const oidcCallbackTimeout = 30 * time.Second
// fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。
//
// 背景Member CenterMC的 id_token 目前不發 email claimASP.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 TLDRFC 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。
@ -49,8 +86,45 @@ type MeResponseOIDC struct {
}
// LogoutResponse 是 POST /api/auth/logout 的 data payload。
//
// IDPLogout 為**選填**:只有當 deps.OIDCLogoutURL 有設定時才回傳,告訴前端
// 「除了清掉 visionA session還要連帶登出 IdPMember Centersession」。
// 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。
type LogoutResponse struct {
Success bool `json:"success"`
// IDPLogout 帶 IdPMC登出資訊供前端觸發 MC 登出(讓使用者能換帳號)。
// nil 時代表未啟用 MC 連動登出。
IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"`
}
// IDPLogoutInfo 描述「如何觸發 IdPMember Center登出」。
//
// 為什麼需要 MethodMC 不支援標準 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 = URLGET觸發 MC :7880 登出
// 3. ⚠️ stage MC 跑 master 舊版2026-04-30 imagelogout **不支援 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 是 IdPMC 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。
@ -228,6 +302,47 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
return
}
// Provision users 列DB-on FK 收尾,問題 #1
//
// D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID。upsert 後 users 表才有
// 這筆使用者,後續帶 owner_user_id FK 的寫入model 上傳 / 配對 / pairing token才不會
// FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK僅維持對稱。
//
// 在「驗 id_token 成功後、寫 session 之前」provisionfail-closed —— provision 失敗就不發
// session否則使用者拿到能登入的 cookie 但 DB 沒對應 user下一個寫入照樣爆且更難診斷
// UserStore 為 nil最小骨架 / 純 OIDC unit test→ 略過 upsert。
//
// email fallback放寬 email 必填MC 現況不發 email claimclaims.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.idD1-B
Email: provisionEmail,
Name: claims.Name,
}); upErr != nil {
// 不洩漏 raw error 給 userlog 留診斷(不含 token / secret
log.Error("oidc.callback: provision user failed",
"request_id", RequestIDFrom(c),
"user_id", claims.Subject,
"error", upErr)
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to provision user", nil)
return
}
}
// Session fixation 防護OWASP ASVS V3.2.1)— Fix-A1 / Major-1。
//
// 在「驗 id_token 成功後、寫使用者 info 進 session 之前」rotate session ID。
@ -325,9 +440,19 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
// oidcLogoutHandler 實作 POST /api/auth/logoutOIDC 模式)。
//
// 雛形不做 RP-initiated logout不通知 IdP— 只清本地 session + cookie
// 行為:一律清掉 visionA 自己的 session + cookie原有行為不變
// Idempotentcookie 不存在或 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 登出,
// 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。
//
// TODOMC 補上標準 RP-initiated logoutGET 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) {
@ -346,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)
}
}
@ -402,4 +537,3 @@ func sanitizeReturnTo(raw string) string {
}
return raw
}

View File

@ -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 keyomitempty 驗證)
assert.NotContains(t, logoutW.Body.String(), "idp_logout")
}
// TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet 驗證設定 OIDCLogoutURL 後,
// logout response 含 idp_logoutURL + 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{}

View File

@ -421,11 +421,43 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
return
}
// Generate Session Token
plaintext, sessionInfo, err := deps.SessionTokenStore.Create(
// 自建 device + 建綁該 device 的 session tokenDB-on FK 收尾,問題 #2
//
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
// 改由 PairingExchanger 在 exchange 時自建一筆 deviceowner = info.UserID該 user 已透過
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback
//
// info.TokenHash 作為 parent_token_hash稽核鏈session token ← pairing token
//
// PairingExchanger 為 nilDB-off 雛形最小骨架,未注入)→ fallback 到舊行為:直接用
// info.DeviceID可能為空建 session token不自建 device。in-memory store 不檢查 FK
// 空 deviceID 可接受;此分支只在 main.go 沒注入 exchanger 時走到。
var (
plaintext string
sessionInfo *auth.SessionToken
deviceID string
)
if deps.PairingExchanger != nil {
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL)
if exErr != nil {
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
"error", exErr,
"user_id", info.UserID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create session token", nil)
return
}
plaintext = res.SessionPlaintext
sessionInfo = res.SessionInfo
deviceID = res.DeviceID
} else {
var err error
plaintext, sessionInfo, err = deps.SessionTokenStore.Create(
ctx,
info.UserID,
info.DeviceID, // Pairing Token 雛形還沒綁 device_id為空沒關係
info.DeviceID, // fallback雛形未綁 device_id為空僅 in-memory 可接受)
info.TokenHash,
auth.SessionTokenTTL,
)
@ -437,6 +469,8 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
"failed to create session token", nil)
return
}
deviceID = info.DeviceID
}
// Mark pairing token as used。
//
@ -445,9 +479,9 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
// 可能再被 exchange 一次。改為 abort撤銷剛產生的 session token、回 500
// 而不是 silent log warn 繼續往前。
//
// 注意deviceID 沿用 info.DeviceID可能為空。雛形 MarkUsed 對空字串
// 是安全的(它只是覆寫欄位)。
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.DeviceID); err != nil {
// 注意deviceID 現在綁的是自建 deviceDB-on或 info.DeviceIDfallback
// MarkUsed 把 pairing token 也綁上同一個 device_id稽核哪台 device 用掉了這個 token)。
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, deviceID); err != nil {
// 嘗試 revoke 剛產生的 session tokenrevoke 自身失敗不再 retry只 log。
revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext)
logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted",
@ -455,7 +489,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
"revoke_err", revokeErr,
"token_prefix", tokenPrefix(req.PairingToken),
"session_token_prefix", tokenPrefix(plaintext),
"device_id", info.DeviceID,
"device_id", deviceID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"pairing token mark-used failed; aborted", nil)
@ -482,7 +516,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
// 故意只 log token prefix避免完整 session token 進日誌
logOrDefault(deps.Logger).Info("pairing exchange: success",
"user_id", info.UserID,
"device_id", info.DeviceID,
"device_id", deviceID,
"session_token_prefix", tokenPrefix(plaintext),
"pairing_token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))

View File

@ -0,0 +1,210 @@
// pairing_exchange.go — pairing exchange 自建 device 的協調者DB-on FK 收尾,問題 #2
//
// 背景:
//
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
// production 路徑會建 devicegrep 確認 device.Save 只在 seed / test 被呼叫。exchange 時
// info.DeviceID 必為空 → DB-on 下 session token INSERT 因 device_id 空字串 cast UUID 失敗。
// in-memory 模式因為不檢查 FK 而藏住此問題。
//
// 修法使用者拍板exchange 時雲端自建 device不動 local-tool
//
// exchange 驗完 pairing token 後、建 session token 之前,雲端自建一筆 device 代表「這台配對
// 進來的 local agent」owner = pairing token 綁的 user這個 user 已透過 OIDC callback
// provision 進 users 表 —— 見問題 #1。然後用這個 device_id 建 session token。
//
// 為什麼抽成 coordinator比照 unpair.go 的 DeviceUnpairer
// - 讓 handlerpairing.go 的 exchange維持薄。
// - Postgres 後端用 db.WithTx 把「建 device + 建 session token」包成單一交易——任一步失敗
// 整筆 rollback杜絕「device 建了但 session token 沒建成」的中間態database.md §6 一致性精神)。
// - in-memory 後端依序執行(無交易),行為一致。
// - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler
// fallback 到「不自建 device、直接用 info.DeviceID可能為空建 session token」的舊行為
// (與 DB-off 雛形相容in-memory store 不檢查 FK空 deviceID 可接受)。
//
// 冪等pairing token 是一次性MarkUsed 後 Validate 回 ErrTokenUsed故同一 token 不會被
// exchange 兩次成功。每次成功 exchange 自建一筆新 device新 UUID是正確語意——不同次配對
// 視為不同 agent 連線。重試exchange 後 MarkUsed 失敗被 abort時 session token 已 revoke、
// device 已建但無 token 指向它(孤兒 device無安全風險僅一筆閒置紀錄雛形可接受
package api
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/auth"
"visiona-backend/internal/db"
"visiona-backend/internal/device"
)
// defaultPairedDeviceName / defaultPairedDeviceType 是 exchange 自建 device 的預設值。
//
// 雛形agent 端 exchange request 只傳 pairing_token、不帶裝置資訊不動 local-tool
// 故 Name / DeviceType 在雲端用預設值。Phase 1 若 agent 帶上 serial / device_type 可改填真值。
const (
defaultPairedDeviceName = "local-tool (paired)"
defaultPairedDeviceType = "local-agent"
)
// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。
type ExchangeProvisionResult struct {
DeviceID string // 本次自建的 device id
SessionPlaintext string // 新 session token 原文caller 只此一次能拿到)
SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt
}
// PairingExchanger 把「自建 device + 建 session token」包成一個原子Postgres tx
// 一致in-memory 依序)操作。
//
// Provision 語意:成功回 ExchangeProvisionResult任一步失敗回 errorhandler 經 errors.go
// 映射成 5xx不洩漏 raw error
type PairingExchanger interface {
// Provision 自建一筆 deviceowner = userID並建一筆綁該 device 的 session token。
//
// parentTokenHash 為來源 pairing token 的 hash稽核鏈寫進 session_tokens.parent_token_hash
Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration) (ExchangeProvisionResult, error)
}
// ── Postgres 後端 ─────────────────────────────────────────────────────────────
// pgDeviceSaver 是 device 在 tx 內 upsert 的能力(由 device.PostgresRepository 滿足)。
type pgDeviceSaver interface {
SaveTx(ctx context.Context, q db.Querier, d *device.Device) error
}
// pgSessionTokenCreator 是「在 tx 內建 session token」的能力由 auth.PostgresSessionTokenStore 滿足)。
type pgSessionTokenCreator interface {
CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
}
// pgPairingExchanger 用單一 pgx 交易完成「自建 device + 建 session token」。
type pgPairingExchanger struct {
pool *pgxpool.Pool
devices pgDeviceSaver
sessionToken pgSessionTokenCreator
log *slog.Logger
}
// NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。
func NewPostgresPairingExchanger(
pool *pgxpool.Pool,
devices pgDeviceSaver,
sessionToken pgSessionTokenCreator,
log *slog.Logger,
) PairingExchanger {
return &pgPairingExchanger{
pool: pool,
devices: devices,
sessionToken: sessionToken,
log: logOrDefault(log),
}
}
// Provision 在單一交易內:自建 device → 建綁該 device 的 session token。
//
// 任一步失敗整筆 rollbackdevice 不會「已建但沒 token」殘留在 DB
func (e *pgPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
) (ExchangeProvisionResult, error) {
var res ExchangeProvisionResult
deviceID := uuid.NewString()
now := time.Now().UTC()
err := db.WithTx(ctx, e.pool, func(q db.Querier) error {
dev := &device.Device{
ID: deviceID,
OwnerUserID: userID,
Name: defaultPairedDeviceName,
DeviceType: defaultPairedDeviceType,
// serial_number 留空agent 未帶SaveTx 把空 serial 寫成 SQL NULL
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device不撞
// partial unique uq_devices_owner_serial_active每個 NULL 互不相等)。
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil {
return fmt.Errorf("exchange: save device: %w", saveErr)
}
plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, deviceID, parentTokenHash, ttl)
if createErr != nil {
return fmt.Errorf("exchange: create session token: %w", createErr)
}
res.DeviceID = deviceID
res.SessionPlaintext = plaintext
res.SessionInfo = info
return nil
})
if err != nil {
return ExchangeProvisionResult{}, err
}
return res, nil
}
// ── in-memory 後端 ────────────────────────────────────────────────────────────
// memSessionTokenCreator 是 in-memory store「建 session token」的能力
// (由 auth.InMemorySessionTokenStore 透過 SessionTokenStore interface 滿足)。
type memSessionTokenCreator interface {
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
}
// memPairingExchanger 依序(非交易)完成自建 device + 建 session token。
//
// in-memory 為單機 local-dev fallback無跨 store 交易需求;依序執行已能保證行為一致。
type memPairingExchanger struct {
devices device.Repository
sessionToken memSessionTokenCreator
}
// NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。
func NewInMemoryPairingExchanger(
devices device.Repository,
sessionToken memSessionTokenCreator,
) PairingExchanger {
return &memPairingExchanger{
devices: devices,
sessionToken: sessionToken,
}
}
// Provision 自建 device 後建綁該 device 的 session token依序非交易
func (e *memPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
) (ExchangeProvisionResult, error) {
deviceID := uuid.NewString()
now := time.Now().UTC()
dev := &device.Device{
ID: deviceID,
OwnerUserID: userID,
Name: defaultPairedDeviceName,
DeviceType: defaultPairedDeviceType,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if err := e.devices.Save(ctx, dev); err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err)
}
plaintext, info, err := e.sessionToken.Create(ctx, userID, deviceID, parentTokenHash, ttl)
if err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
}
return ExchangeProvisionResult{
DeviceID: deviceID,
SessionPlaintext: plaintext,
SessionInfo: info,
}, nil
}

View File

@ -0,0 +1,118 @@
//go:build dbtest
// Postgres pairing exchange 自建 device 的真 DB 整合測試DB-on FK 收尾,問題 #2
//
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers時編譯/執行。
// 預設 `go test ./...`(無 Docker不觸碰本檔維持綠燈。
//
// 執行:
//
// go test -tags=dbtest ./internal/api/...
// # 無本機 Docker 時Orchestrator 在 130 補跑:
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest ./internal/api/...
//
// 涵蓋:
// - Provision 成功:自建一筆 deviceowner 對齊)+ 建綁該 device 的 session tokensession
// token 的 device_id 不再為空、且確實指向新建的 deviceFK 滿足)。
// - parent_token_hash 寫入(稽核鏈)。
// - 原子性device owner 不存在FK violation→ 整筆 rollbackdevice 不會殘留。
package api
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/auth"
"visiona-backend/internal/db/testsupport"
"visiona-backend/internal/device"
)
// pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user無 device、無 token
func pgExchangeFixture(t *testing.T) (
tdb *testsupport.TestDB,
exchanger PairingExchanger,
devRepo *device.PostgresRepository,
sessions *auth.PostgresSessionTokenStore,
owner string,
) {
t.Helper()
tdb = testsupport.SetupTestDB(t)
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
owner = tdb.EnsureDemoUser(t)
devRepo = device.NewPostgresRepository(tdb.Pool)
sessions = auth.NewPostgresSessionTokenStore(tdb.Pool)
exchanger = NewPostgresPairingExchanger(tdb.Pool, devRepo, sessions, nil)
return
}
// TestPGExchange_ProvisionCreatesDeviceAndSession 驗證 exchange 自建 device + session token
// 且 session token 的 device_id 綁定到新建的 device之前 DB-on 會因 device_id 空字串失敗)。
func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
ctx := context.Background()
tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t)
parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32])
res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL)
require.NoError(t, err)
require.NotEmpty(t, res.DeviceID, "應自建一筆 device")
require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token")
require.NotNil(t, res.SessionInfo)
// 1) device 真的進 DB、owner 對齊
dev, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.Equal(t, owner, dev.OwnerUserID)
assert.Equal(t, defaultPairedDeviceName, dev.Name)
assert.Equal(t, defaultPairedDeviceType, dev.DeviceType)
assert.NotNil(t, dev.PairedAt, "自建 device 應設 paired_at")
// 2) session token 真的進 DB、device_id 綁到新建 device非空、FK 滿足)
tok, err := sessions.Get(ctx, res.SessionPlaintext)
require.NoError(t, err)
assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 的 device_id 應綁到自建 device")
assert.Equal(t, owner, tok.UserID)
assert.Equal(t, parentHash, tok.ParentTokenHash, "parent_token_hash 應為來源 pairing token hash稽核鏈")
// 3) 直接查 DB 確認 session_tokens.device_id 非 NULL
var deviceIDIsNull bool
require.NoError(t, tdb.Pool.QueryRow(ctx,
`SELECT device_id IS NULL FROM session_tokens WHERE token_hash = $1`,
auth.HashToken(res.SessionPlaintext)).Scan(&deviceIDIsNull))
assert.False(t, deviceIDIsNull, "session_tokens.device_id 不應為 NULL")
}
// TestPGExchange_Provision_RollbackOnBadOwner 驗證原子性owner 不存在於 usersFK violation
// → device INSERT 撞 owner_user_id FK → 整筆 rollbackdevice 不殘留。
func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) {
ctx := context.Background()
tdb, exchanger, _, _, _ := pgExchangeFixture(t)
badOwner := uuid.NewString() // 不在 users 表
_, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL)
require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation")
// device 不應殘留(整筆交易 rollback
assert.Equal(t, 0, tdb.CountRows(t, "devices"), "FK 失敗應 rollback無 device 殘留")
assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "session token 也不應建立")
}
// TestPGExchange_Provision_MultipleCreatesDistinctDevices 驗證多次 exchange 各自建新 device
// (不同 UUID——對齊「不同次配對視為不同 agent 連線」的冪等語意。
func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) {
ctx := context.Background()
_, exchanger, _, _, owner := pgExchangeFixture(t)
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
require.NoError(t, err)
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device")
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同")
}

View File

@ -0,0 +1,59 @@
// memPairingExchanger 的單元測試DB-on FK 收尾,問題 #2
//
// 不帶 build tag屬於預設 `go test ./...` 範圍(無需 Docker
// 驗證 in-memory exchanger 自建 device + 建 session token、且 session token 綁到該 device
// 與 pairing_exchange_db_test.go 的 Postgres dbtest 對齊行為。
package api
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/auth"
"visiona-backend/internal/device"
)
func TestMemExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
ctx := context.Background()
devRepo := device.NewInMemoryRepository()
sessions := auth.NewInMemorySessionTokenStore()
exchanger := NewInMemoryPairingExchanger(devRepo, sessions)
res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL)
require.NoError(t, err)
require.NotEmpty(t, res.DeviceID)
require.NotEmpty(t, res.SessionPlaintext)
require.NotNil(t, res.SessionInfo)
// device 自建、owner 對齊
dev, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.Equal(t, "owner-1", dev.OwnerUserID)
assert.Equal(t, defaultPairedDeviceName, dev.Name)
assert.Equal(t, defaultPairedDeviceType, dev.DeviceType)
assert.NotNil(t, dev.PairedAt)
// session token 綁到自建 device
tok, err := sessions.Get(ctx, res.SessionPlaintext)
require.NoError(t, err)
assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 應綁到自建 device")
assert.Equal(t, "owner-1", tok.UserID)
assert.Equal(t, "parent-hash", tok.ParentTokenHash)
}
func TestMemExchange_Provision_DistinctDevices(t *testing.T) {
ctx := context.Background()
exchanger := NewInMemoryPairingExchanger(
device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore())
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
require.NoError(t, err)
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
}

View File

@ -0,0 +1,307 @@
// storage_test.go — /storage/* presigned 代理 handler 的 unit test。
//
// Owner: testing agent補測任務 — internal/api 弱處 handler
//
// 測試對象storage.go 的 storageGetHandler / storagePutHandler / verifyStorageSignature /
// storageKeyFromPath / registerStorageRoutes。
//
// 策略:用真 *storage.LocalFSStoret.TempDir() 後端、unit 性質、不需 docker+ httptest 打 handler。
// 簽章用 store 的 PresignedGetURL / PresignedPutURL 產生(與生產同一條簽章邏輯),
// 再從產出的 URL 拆出 expires / signature 組請求 — 避免測試自己重刻 HMAC會脆弱
package api
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/storage"
)
func init() {
gin.SetMode(gin.TestMode)
}
// newStorageTestServer 建立一個只掛 /storage/* 路由的 gin engineHMAC 控管、不經 AuthMiddleware
func newStorageTestServer(t *testing.T, deps Deps) *gin.Engine {
t.Helper()
r := gin.New()
registerStorageRoutes(r, deps)
return r
}
// newLocalStore 建立一個 t.TempDir() 後端、固定 secret 的 LocalFSStore。
func newLocalStore(t *testing.T) *storage.LocalFSStore {
t.Helper()
s, err := storage.NewLocalFSStore(t.TempDir(), "http://localhost:3721/storage", "storage-test-secret")
require.NoError(t, err)
return s
}
// signedQuery 用 store 的 presigned 邏輯產生 method 對應的 (expires, signature) query 字串。
// 回傳形如 "expires=...&signature=..." 的 raw query不含前導 ?)。
func signedQuery(t *testing.T, s *storage.LocalFSStore, method, key string, ttl time.Duration) string {
t.Helper()
var raw string
var err error
switch method {
case http.MethodGet:
raw, err = s.PresignedGetURL(context.Background(), key, ttl)
case http.MethodPut:
raw, err = s.PresignedPutURL(context.Background(), key, ttl)
default:
t.Fatalf("unsupported method %q", method)
}
require.NoError(t, err)
u, err := url.Parse(raw)
require.NoError(t, err)
q := u.Query()
// 只保留 expires / signaturemode 不影響 handler 驗簽)。
out := url.Values{}
out.Set("expires", q.Get("expires"))
out.Set("signature", q.Get("signature"))
return out.Encode()
}
// ───────────────────────── storageKeyFromPath純函式─────────────────────────
func TestStorageKeyFromPath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"happy: 去掉前導斜線", "/models/u1/a.nef", "models/u1/a.nef"},
{"empty: 空字串", "", ""},
{"boundary: 只有一個斜線", "/", ""},
{"無前導斜線時原樣回傳", "models/a.nef", "models/a.nef"},
{"巢狀深路徑", "/a/b/c/d.bin", "a/b/c/d.bin"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, storageKeyFromPath(tc.in))
})
}
}
// ───────────────────────── registerStorageRoutes ─────────────────────────
// Storage 為 nil 時不註冊任何 /storage/* 路由handler 不存在 → gin 回 404
func TestRegisterStorageRoutes_NilStorage_NoRoutes(t *testing.T) {
t.Parallel()
r := newStorageTestServer(t, Deps{Storage: nil})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/storage/models/x.nef?expires=1&signature=abc", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code,
"Storage 為 nil 時不應註冊路由gin 對未知路由回 404")
}
// ───────────────────────── PUT happy + GET happyround-trip─────────────────────────
// 完整 round-trip簽章 PUT 寫入 → 簽章 GET 讀回,內容一致。
func TestStoragePut_Then_Get_RoundTrip(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
key := "models/user-1/round.nef"
payload := []byte("visiona-round-trip-bytes")
// PUT
putW := httptest.NewRecorder()
putReq := httptest.NewRequest(http.MethodPut,
"/storage/"+key+"?"+signedQuery(t, s, http.MethodPut, key, time.Hour),
bytes.NewReader(payload))
putReq.ContentLength = int64(len(payload))
r.ServeHTTP(putW, putReq)
require.Equal(t, http.StatusNoContent, putW.Code, "簽章正確的 PUT 應回 204")
// 底層 storage 確實有寫入
gotObj, _, err := s.Get(context.Background(), key)
require.NoError(t, err)
_ = gotObj.Close()
// GET
getW := httptest.NewRecorder()
getReq := httptest.NewRequest(http.MethodGet,
"/storage/"+key+"?"+signedQuery(t, s, http.MethodGet, key, time.Hour), nil)
r.ServeHTTP(getW, getReq)
require.Equal(t, http.StatusOK, getW.Code, "簽章正確的 GET 應回 200")
assert.Equal(t, payload, getW.Body.Bytes(), "GET 內容應與 PUT 寫入一致")
assert.Equal(t, "application/octet-stream", getW.Header().Get("Content-Type"))
assert.Equal(t, "24", getW.Header().Get("Content-Length"))
}
// ───────────────────────── GET 錯誤路徑 ─────────────────────────
// GET 缺 signature/expires → 403 INVALID_SIGNATURE。
func TestStorageGet_MissingSignature_403(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
w := httptest.NewRecorder()
// 沒有任何 query 參數
req := httptest.NewRequest(http.MethodGet, "/storage/models/u1/a.nef", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature)
}
// GET 簽章被竄改 → 403。
func TestStorageGet_TamperedSignature_403(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
key := "models/u1/a.nef"
q := signedQuery(t, s, http.MethodGet, key, time.Hour)
tampered := strings.Replace(q, "signature=", "signature=ZZZ", 1)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/storage/"+key+"?"+tampered, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature)
}
// GET 簽章已過期boundary負 ttl → expires 在過去)→ 403。
func TestStorageGet_ExpiredSignature_403(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
key := "models/u1/a.nef"
// ttl 為負 → presignedURL 產出的 expires 在過去 → VerifySignature 走過期分支
q := signedQuery(t, s, http.MethodGet, key, -time.Hour)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/storage/"+key+"?"+q, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature)
}
// GET 簽章正確但 object 不存在 → 404 NOT_FOUND。
func TestStorageGet_NotFound_404(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
key := "models/u1/missing.nef" // 從未 Put 過
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/storage/"+key+"?"+signedQuery(t, s, http.MethodGet, key, time.Hour), nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
}
// expires 非數字 → verifyStorageSignature 在 ParseInt 失敗 → 403。
func TestStorageGet_NonNumericExpires_403(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/storage/models/u1/a.nef?expires=not-a-number&signature=abc", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature)
}
// ───────────────────────── PUT 錯誤路徑 ─────────────────────────
// PUT 缺簽章 → 403且不應寫入底層 storage。
func TestStoragePut_MissingSignature_403_NoWrite(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
key := "models/u1/nowrite.nef"
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/storage/"+key,
bytes.NewReader([]byte("should-not-persist")))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature)
// 驗簽失敗 → 不應寫入
_, _, err := s.Get(context.Background(), key)
assert.ErrorIs(t, err, storage.ErrNotFound, "驗簽失敗的 PUT 不應寫入 storage")
}
// PUT 用 GET 方法的簽章method mismatch→ 403簽章把 method 綁進 payload
func TestStoragePut_WrongMethodSignature_403(t *testing.T) {
s := newLocalStore(t)
r := newStorageTestServer(t, Deps{Storage: s})
key := "models/u1/methodmix.nef"
// 用 GET 簽章去打 PUT
q := signedQuery(t, s, http.MethodGet, key, time.Hour)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/storage/"+key+"?"+q,
bytes.NewReader([]byte("x")))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code,
"GET 簽章不能用於 PUTmethod 綁進簽章 payload")
}
// ───────────────────────── verifyStorageSignature: 非 LocalFS backend ─────────────────────────
// fakeNonLocalStore 是一個非 *LocalFSStore 的 storage.Store 實作,
// 用來驗 verifyStorageSignature 對「非 LocalFS backend」直接回 ErrInvalidSignature。
type fakeNonLocalStore struct{}
func (fakeNonLocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error {
return nil
}
func (fakeNonLocalStore) Get(ctx context.Context, key string) (io.ReadCloser, *storage.Object, error) {
return nil, nil, storage.ErrNotFound
}
func (fakeNonLocalStore) Stat(ctx context.Context, key string) (*storage.Object, error) {
return nil, storage.ErrNotFound
}
func (fakeNonLocalStore) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
func (fakeNonLocalStore) Delete(ctx context.Context, key string) error { return nil }
func (fakeNonLocalStore) List(ctx context.Context, prefix string) ([]*storage.Object, error) {
return nil, nil
}
func (fakeNonLocalStore) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
return "", nil
}
func (fakeNonLocalStore) PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
return "", nil
}
// 非 LocalFS backend即使帶 queryverifyStorageSignature type-assert 失敗 → 403。
func TestStorageGet_NonLocalFSBackend_403(t *testing.T) {
r := newStorageTestServer(t, Deps{Storage: fakeNonLocalStore{}})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/storage/models/u1/a.nef?expires=99999999999&signature=whatever", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code,
"非 *LocalFSStore backend 不應通過 /storage 驗簽")
assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature)
}

View File

@ -57,6 +57,17 @@ const sessionColumns = `token_hash, user_id, device_id, parent_token_hash,
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用DB 不存)。
func (s *PostgresSessionTokenStore) Create(
ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration,
) (string, *SessionToken, error) {
return s.CreateTx(ctx, s.pool, userID, deviceID, parentTokenHash, ttl)
}
// CreateTx 與 Create 相同的語意,但在傳入的 Querierpool 或 tx上執行。
//
// 用於 pairing exchange 自建 device 時,與 device 建立在同一交易內pairing 收尾問題 #2
// 「建 device + 建 session token」整筆原子。q 可為 *pgxpool.Pool自動 commit或 pgx.Tx
// 隨外層交易。device_id NOT NULLsession token 必綁 devicecaller 須先在同 tx 建好 device。
func (s *PostgresSessionTokenStore) CreateTx(
ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration,
) (string, *SessionToken, error) {
plaintext, err := GenerateSessionToken()
if err != nil {
@ -86,10 +97,10 @@ func (s *PostgresSessionTokenStore) Create(
parentArg = parentTokenHash
}
const q = `INSERT INTO session_tokens
const sql = `INSERT INTO session_tokens
(token_hash, user_id, device_id, parent_token_hash, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`
if _, err := s.pool.Exec(ctx, q,
if _, err := q.Exec(ctx, sql,
info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt,
); err != nil {
return "", nil, fmt.Errorf("auth: pg session Create: %w", err)

View File

@ -304,10 +304,16 @@ func TestPGSession_ContextCancel(t *testing.T) {
assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error")
}
// tdbCountActive 計算某 owner 仍未過期CleanupExpired 後殘留)的 session token 數。
// 用 CleanupExpired 後再跑一次 0-removed 的方式間接驗證殘留數。
// tdbCountActive 直接 SELECT count(*) 該 owner 在 session_tokens 表中殘留的列數。
//
// 呼叫端固定在 CleanupExpired 之後使用:過期列已被刪除,故殘留列數即「未過期列數」。
// 這是直接查 DB 殘留量,不另跑 CleanupExpired。
//
// 參數 deviceID 目前未用於查詢(殘留量以 owner 為準即可),保留於簽章以利日後若需
// 收斂到單一 device 時擴充查詢,不更動呼叫端。
func tdbCountActive(ctx context.Context, t *testing.T, s *PostgresSessionTokenStore, owner, deviceID string) int {
t.Helper()
_ = deviceID // 見上方註解:目前以 owner 為計數範圍。
var n int
err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM session_tokens WHERE user_id = $1`, owner).Scan(&n)

View File

@ -118,6 +118,41 @@ type OIDCConfig struct {
// 對齊 VISIONA_FRONTEND_URL沿用 oidc-tdd.md §13.1 命名)。
PostLoginURL string
// LogoutURL 是「讓使用者連帶登出 IdPMember Centersession」的入口 URL。
//
// 背景MC 限制2026-06MC 不支援標準 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 共享 DataProtectionSetApplicationName("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。
//
// TODOMC 補標準 RP-initiated logout 後):改回後端 302 導向標準 end_session_endpoint
// 移除這個權宜欄位。
LogoutURL string
// PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login`
// - true → 每次登入都讓 IdP 強制重新認證(忽略既有 SSO session
// - false → 沿用 IdP 既有 session標準 SSO為預設值。
// stage 想「每次都重新輸入帳密」時設 trueprod 視 UX 決定。
// 對齊 VISIONA_OIDC_PROMPT_LOGIN預設 false
PromptLogin bool
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id
// 預留給未來 client_credentials grant flow例如查詢使用者組織、推送通知等
//

View File

@ -38,6 +38,11 @@ func Load() *Config {
ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""),
RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""),
PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""),
// LogoutURLMC Web :7880 logout 入口GET /account/logout。留空logout 只清本地、
// 不連動 MC向下相容見 OIDCConfig.LogoutURL 註解)。
LogoutURL: getEnvString("VISIONA_OIDC_LOGOUT_URL", ""),
// prompt=logintrue 時每次登入都強制 IdP 重新認證(忽略既有 SSO session
PromptLogin: getEnvBool("VISIONA_OIDC_PROMPT_LOGIN", false),
// A1client_credentials grant 預留欄位,留空表「不啟用 service client」。
ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""),
ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""),

View File

@ -165,6 +165,23 @@ type ConverterJob struct {
ErrorCode string // 取自 error.code
ErrorMessage string // 取自 error.message
TargetObjectKey string // 僅 promote 後才有GET / list 時為 ""
// ── 模型 metadataB4optional / 防禦性)─────────────────────────────────
//
// 來源:轉檔服務端 bie worker 的 analysis_infoservices/workers/bie/core.py
// 轉檔完成status=completedconverter 端**預期**會把 analysis_info 串進
// `GET /api/v1/jobs/{id}` 的 response見下方 converterJobJSON.AnalysisInfo 假設的 schema
//
// **這幾個欄位全部 optional**:轉檔端尚未串好 analysis_info 時(目前狀態),
// 這些欄位維持零值nil / ""),不影響建 modelInputShape 留空即可)。
//
// 轉檔端 result schema 對齊點(待轉檔端交接檔確認):見 converterJobJSON.AnalysisInfo。
//
// InputShape 對映bie worker 給的 {batch_size, channels, height, width}
// → []int{batch, channel, height, width}NCHW 順序,對齊 Model.InputShape 與 PG INT[] 既有測試 [1,3,224,224])。
InputShape []int // e.g. [1, 3, 224, 224]NCHW轉檔端沒給 → nil
Classes []string // 類別標籤;轉檔端沒給 → nil
Framework string // 原始框架("onnx" / "tflite" / ...);轉檔端沒給 → ""
}
// ConverterPromoteResult 是 Promote 的 response shape。
@ -675,6 +692,7 @@ func (c *converterClient) mapListJobsError(status int, body []byte) error {
// - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同
//
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
//
// — request body 可能在 retry 時已被讀完必須重建。caller 內部用 bytes.NewReader 等可重建的 body。
func (c *converterClient) doWithRetry(
ctx context.Context,
@ -894,6 +912,7 @@ func extractActiveJobFromDetails(raw json.RawMessage) *Job {
// 為了同時支援:
// - CreateJobResponsePOST /jobs 201— 無 stage_progress / input.filename 等欄位
// - JobGET /jobs/{id})— 完整欄位
//
// 全部欄位都用 pointer 或 nullableMarshal 時靠下方 toConverterJob 統一轉。
type converterJobJSON struct {
JobID string `json:"job_id"`
@ -915,6 +934,67 @@ type converterJobJSON struct {
Message string `json:"message"`
Stage string `json:"stage"`
} `json:"error"`
// AnalysisInfo 是 B4 模型 metadata 的**假設 schema**(轉檔端尚未定案)。
//
// ⚠️ 轉檔端對齊點(會寫進交接檔,轉檔端做的時候依此實作或回頭調整):
// visionA 假設 converter 在 `GET /api/v1/jobs/{id}` 的 top-level 放一個
// `analysis_info` 物件,形如:
//
// "analysis_info": {
// "input_shape": [1, 3, 224, 224], // 優先NCHW 順序
// "batch_size": 1, // 後備input_shape 缺時用這 4 個組)
// "channels": 3,
// "height": 224,
// "width": 224,
// "classes": ["face", "person"], // optional
// "framework": "onnx" // optional
// }
//
// 來源是 bie worker 的 analysis_infoinput_name/batch_size/channels/height/width
// visionA 同時接受兩種 input_shape 表示法(防呆,看轉檔端最後給哪種):
// (a) 明確的 `input_shape` 陣列 → 直接用
// (b) 拆開的 batch_size/channels/height/width → 組成 [batch, channel, height, width]
//
// **全部欄位 pointer / nullable**:轉檔端沒串好(目前狀態)→ AnalysisInfo 整個為 nil
// 或欄位為空 → 對映出的 metadata 全為零值,不報錯。
AnalysisInfo *converterAnalysisInfoJSON `json:"analysis_info"`
}
// converterAnalysisInfoJSON 是 B4 模型 metadata 的中介 unmarshal type。
//
// 對齊 converterJobJSON.AnalysisInfo 註解的假設 schema轉檔端待對齊
// 全欄位 pointer / slice — 任一缺漏都不影響解析(缺 → 零值)。
type converterAnalysisInfoJSON struct {
InputShape []int `json:"input_shape"` // 優先表示法
BatchSize *int `json:"batch_size"` // 後備:拆開的 4 維
Channels *int `json:"channels"`
Height *int `json:"height"`
Width *int `json:"width"`
Classes []string `json:"classes"`
Framework string `json:"framework"`
}
// toInputShape 把 analysis_info 對映成 Model.InputShape 用的 []intNCHW
//
// 優先序:
// 1. 明確的 input_shape 陣列(非空)→ 直接回傳
// 2. 後備batch_size/channels/height/width 四維**全部齊全**才組
// [batch, channel, height, width];任一缺 → 回 nil不亂組半套維度
// 3. 都沒有 → nil轉檔端尚未串好的目前狀態
func (a *converterAnalysisInfoJSON) toInputShape() []int {
if a == nil {
return nil
}
if len(a.InputShape) > 0 {
out := make([]int, len(a.InputShape))
copy(out, a.InputShape)
return out
}
if a.BatchSize != nil && a.Channels != nil && a.Height != nil && a.Width != nil {
return []int{*a.BatchSize, *a.Channels, *a.Height, *a.Width}
}
return nil
}
// parseConverterJob 解 GET /api/v1/jobs/{id} 或 POST /api/v1/jobs 201 的 response。
@ -953,6 +1033,14 @@ func (jr *converterJobJSON) toConverterJob() *ConverterJob {
cj.ErrorCode = jr.Error.Code
cj.ErrorMessage = jr.Error.Message
}
// B4 模型 metadataoptional / 防禦性):轉檔端沒串 analysis_info → 全留零值。
if jr.AnalysisInfo != nil {
cj.InputShape = jr.AnalysisInfo.toInputShape()
if len(jr.AnalysisInfo.Classes) > 0 {
cj.Classes = append([]string(nil), jr.AnalysisInfo.Classes...)
}
cj.Framework = jr.AnalysisInfo.Framework
}
return cj
}

View File

@ -1274,11 +1274,11 @@ func TestGetResult_EmptyJobID(t *testing.T) {
// TestParseFilenameFromContentDispositioncover parser 的 happy / empty / malformed case。
//
// v0.6 T3 s-5 補強reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case
// - RFC 5987 encoded form`filename*=UTF-8''...`)— 驗 Go stdlib `mime.ParseMediaType`
// - RFC 5987 encoded form`filename*=UTF-8...`)— 驗 Go stdlib `mime.ParseMediaType`
// 對 charset-encoded `filename*` 參數的 transparent 解碼行為
// - Hostile-inputCRLF injection / path traversal / null byte / extreme length
//
// **重要發現**Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8''...` 形式
// **重要發現**Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8...` 形式
// **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。
// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded
// `foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。
@ -1339,6 +1339,149 @@ func TestParseFilenameFromContentDisposition(t *testing.T) {
}
}
// ==========================================================================
// B4analysis_info → ConverterJob metadata 對映parse 層)
// ==========================================================================
// TestParseConverterJob_AnalysisInfo_InputShapeGET /jobs/{id} response 帶 analysis_info
// 含明確 input_shape 陣列 → ConverterJob.InputShape / Classes / Framework 正確對映。
//
// 對齊 converterJobJSON.AnalysisInfo 假設 schemainput_shape 為 NCHW [1,3,224,224]。
func TestParseConverterJob_AnalysisInfo_InputShape(t *testing.T) {
t.Parallel()
body := []byte(`{
"job_id": "j-meta-1",
"status": "completed",
"stage": null,
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"input": {"filename": "yolov5s.onnx"},
"parameters": {"platform": "720"},
"analysis_info": {
"input_shape": [1, 3, 224, 224],
"classes": ["face", "person"],
"framework": "onnx"
}
}`)
cj, err := parseConverterJob(body)
require.NoError(t, err)
require.NotNil(t, cj)
assert.Equal(t, []int{1, 3, 224, 224}, cj.InputShape,
"明確 input_shape 陣列應直接對映NCHW對齊 PG INT[] 既有測試 [1,3,224,224]")
assert.Equal(t, []string{"face", "person"}, cj.Classes)
assert.Equal(t, "onnx", cj.Framework)
}
// TestParseConverterJob_AnalysisInfo_DimsFallbackanalysis_info 沒給明確 input_shape
// 但給拆開的 batch_size/channels/height/width 四維 → 組成 [batch, channel, height, width]NCHW
//
// 對映 bie worker 原生輸出input_name/batch_size/channels/height/width
func TestParseConverterJob_AnalysisInfo_DimsFallback(t *testing.T) {
t.Parallel()
body := []byte(`{
"job_id": "j-meta-2",
"status": "completed",
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"analysis_info": {
"batch_size": 1,
"channels": 3,
"height": 640,
"width": 480,
"framework": "tflite"
}
}`)
cj, err := parseConverterJob(body)
require.NoError(t, err)
require.NotNil(t, cj)
assert.Equal(t, []int{1, 3, 640, 480}, cj.InputShape,
"拆開的四維應組成 NCHW [batch, channel, height, width]")
assert.Equal(t, "tflite", cj.Framework)
assert.Nil(t, cj.Classes, "未給 classes → nil")
}
// TestParseConverterJob_AnalysisInfo_PartialDims_NoGuess四維只給一部分缺 width
// 不亂組半套維度InputShape 留 nil防呆
func TestParseConverterJob_AnalysisInfo_PartialDims_NoGuess(t *testing.T) {
t.Parallel()
body := []byte(`{
"job_id": "j-meta-3",
"status": "completed",
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"analysis_info": {
"batch_size": 1,
"channels": 3,
"height": 224
}
}`)
cj, err := parseConverterJob(body)
require.NoError(t, err)
require.NotNil(t, cj)
assert.Nil(t, cj.InputShape, "四維缺一(缺 width→ 不亂組InputShape 留 nil")
}
// TestParseConverterJob_AnalysisInfo_Missing完全沒有 analysis_info轉檔端尚未串好的目前狀態
// → metadata 全留零值、不報錯、不 panic。這是 B4 防禦性的核心 case。
func TestParseConverterJob_AnalysisInfo_Missing(t *testing.T) {
t.Parallel()
body := []byte(`{
"job_id": "j-meta-4",
"status": "completed",
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"input": {"filename": "model.onnx"},
"parameters": {"platform": "520"}
}`)
cj, err := parseConverterJob(body)
require.NoError(t, err, "缺 analysis_info 不應報錯(轉檔端尚未串好)")
require.NotNil(t, cj)
assert.Nil(t, cj.InputShape, "缺 analysis_info → InputShape 留 nil")
assert.Nil(t, cj.Classes)
assert.Empty(t, cj.Framework)
// 其餘欄位照常解析、不受影響
assert.Equal(t, "completed", cj.Status)
assert.Equal(t, "model.onnx", cj.SourceFilename)
}
// TestParseConverterJob_AnalysisInfo_EmptyObjectanalysis_info 物件存在但所有欄位皆缺
// → 同樣全零值、不報錯(防禦:轉檔端送了空殼物件)。
func TestParseConverterJob_AnalysisInfo_EmptyObject(t *testing.T) {
t.Parallel()
body := []byte(`{
"job_id": "j-meta-5",
"status": "completed",
"created_at": "2026-04-25T12:00:00Z",
"updated_at": "2026-04-25T12:05:30Z",
"analysis_info": {}
}`)
cj, err := parseConverterJob(body)
require.NoError(t, err)
require.NotNil(t, cj)
assert.Nil(t, cj.InputShape, "analysis_info 空物件 → InputShape 留 nil")
assert.Nil(t, cj.Classes)
assert.Empty(t, cj.Framework)
}
// TestConverterAnalysisInfoJSON_ToInputShape_NilReceivernil *converterAnalysisInfoJSON
// 呼叫 toInputShape 不 panic、回 nilnil-safe 保證)。
func TestConverterAnalysisInfoJSON_ToInputShape_NilReceiver(t *testing.T) {
t.Parallel()
var a *converterAnalysisInfoJSON
assert.Nil(t, a.toInputShape(), "nil receiver 應回 nil、不 panic")
}
// ==========================================================================
// 共用interface 契約 + helpers
// ==========================================================================

View File

@ -82,6 +82,12 @@ type ModelRecord struct {
FileSize int64
FileChecksum string
TargetChip string
// ── 模型 metadataB4optional─────────────────────────────────────────
// 來源converter job 的 analysis_info轉檔端 bie worker。轉檔端尚未串好時
// 全留零值nil / ""adapter 對映到 model.Model 後一樣留空、不影響建 model。
InputShape []int // e.g. [1, 3, 224, 224]NCHW轉檔端沒給 → nil
Classes []string // 類別標籤;轉檔端沒給 → nil
Framework string // 原始框架;轉檔端沒給 → ""
Source string // 永遠 "converted"
SourceJobID string
// FAAObjectKey 是該 model 在 FAA 上的 object keyADR-017 (a) B1
@ -673,6 +679,12 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
FileSize: promoteRes.Size,
FileChecksum: promoteRes.Checksum,
TargetChip: normalizeTargetChip(cj.Platform),
// B4 模型 metadata從 converter job 的 analysis_info 接過來cj 由上面 step 2
// 的 GetJob 取得)。轉檔端尚未串好 analysis_info 時 cj.InputShape 等為零值 →
// 這裡也維持零值,不影響建 modelInputShape 留空)。
InputShape: cj.InputShape,
Classes: cj.Classes,
Framework: cj.Framework,
Source: "converted",
SourceJobID: jobID,
FAAObjectKey: faaObjectKey,
@ -841,6 +853,7 @@ func defaultDownloadFilename(cj *ConverterJob) string {
//
// 用 modelStore.FindBySourceJobID 當 source-of-truth若已有 model record 表示
// PromoteToModels 已成功跑過,可直接從 record 拿 storage_key 反推 target_object_key
//
// ✗ 不行storage_key 是 visionA storage 的 key不是 FAA 的 object_key。
//
// 改用 converter.Promote 冪等性§2.7「promote 動作是冪等的converter 端對同一

View File

@ -15,10 +15,15 @@
//
// Phase 0.8 conversion (見 docs/autoflow/04-architecture/conversion.md §2.7)
// Phase 0.8b T4DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken
//
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
//
// Phase 0.8b v0.6 T2DownloadStream / PromoteToModels 改走 converter.GetResult
//
// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6)
//
// Phase 0.8b v0.6 T3flowStubFAA + flowFixture.faa 欄位整段砍除ADR-016 撤回 FAA 直連、
//
// faa_client.go 整檔刪除FlowOpts.FAA 必填校驗一併移除。
package conversion
@ -860,6 +865,71 @@ func TestPromoteToModels_HappyPath(t *testing.T) {
// assertion 雙重防護conversion_e2e_test.go:TestConversionE2E_DownloadStream
}
// TestPromoteToModels_Metadata_WrittenToRecordB4 — converter job 帶 analysis_info
// 對映出的 InputShape / Classes / FrameworkPromoteToModels 建 model record 時應寫進去。
//
// 驗完整鏈路在 flow 層的最後一段ConverterJob.InputShape → ModelRecord.InputShape。
func TestPromoteToModels_Metadata_WrittenToRecord(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j-meta",
Status: "completed",
CreatedAt: time.Now().UTC(),
SourceFilename: "yolov5s.onnx",
Platform: "720",
// B4 metadata模擬轉檔端已串好 analysis_info
InputShape: []int{1, 3, 224, 224},
Classes: []string{"face", "person"},
Framework: "onnx",
})
fix.ownership.Set("j-meta", "user-alice")
res, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j-meta", "my-model")
require.NoError(t, err)
require.NotNil(t, res)
// 驗 model record 寫入了 metadata從 stub store 撈回比對)
fix.models.mu.Lock()
rec := fix.models.records[res.ModelID]
fix.models.mu.Unlock()
require.NotNil(t, rec)
assert.Equal(t, []int{1, 3, 224, 224}, rec.InputShape,
"PromoteToModels 應把 ConverterJob.InputShape 寫進 ModelRecordNCHW")
assert.Equal(t, []string{"face", "person"}, rec.Classes)
assert.Equal(t, "onnx", rec.Framework)
}
// TestPromoteToModels_Metadata_Absent_StillSucceedsB4 防禦性 — converter job 沒帶
// analysis_info轉檔端尚未串好→ 建 model 照常成功、metadata 留零值、不報錯。
func TestPromoteToModels_Metadata_Absent_StillSucceeds(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j-nometa",
Status: "completed",
CreatedAt: time.Now().UTC(),
SourceFilename: "model.onnx",
Platform: "520",
// 刻意不設 InputShape / Classes / Framework轉檔端尚未串好
})
fix.ownership.Set("j-nometa", "user-bob")
res, err := fix.svc.PromoteToModels(context.Background(), "user-bob", "j-nometa", "")
require.NoError(t, err, "缺 metadata 不應影響建 model")
require.NotNil(t, res)
fix.models.mu.Lock()
rec := fix.models.records[res.ModelID]
fix.models.mu.Unlock()
require.NotNil(t, rec)
assert.Nil(t, rec.InputShape, "缺 analysis_info → InputShape 留 nil")
assert.Nil(t, rec.Classes)
assert.Empty(t, rec.Framework)
}
// TestPromoteToModels_DefaultNamecaller 傳空 name 應走 fallback `<stem>_kl<chip>`。
func TestPromoteToModels_DefaultName(t *testing.T) {
t.Parallel()

View File

@ -0,0 +1,506 @@
//go:build realconv
// real_chain_e2e_test.go — 完整鏈路 e2e真轉檔 → PromoteToModels → 真 model 進 PG 模型庫。
//
// Owner: testing agentrealconv 完整鏈路 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.PromoteToModelspromote→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 PromoteToModelspromote 與 download 都走
//
// `converter.GetResult`converter MinIOvisionA 端**不再直接打 FAA**。所以本鏈路
// **不需要** wire FAA / MC client —— 只需要:真 ConverterClient:9501 + API key
// + 真 PG建 model record+ 一個 storagestreaming 寫 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 覆寫)
//
// 本機通常連不到 stageVPN/網路)+ 無 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"
)
// ==========================================================================
// 環境 guardPG DSN 是本檔額外需要的converter / fixture 沿用 requireRealConvEnv
// ==========================================================================
const (
// realChainPGDSNEnv 是已 migrate 的 PostgreSQL DSN
// postgres://visiona:pw@192.168.0.130:5432/visiona?sslmode=disable
// 缺 → 本檔的完整鏈路測試 Skipcontract 測試在 real_converter_e2e_test.go 仍可獨立跑)。
realChainPGDSNEnv = "VISIONA_REAL_PG_DSN"
// 真 KTC 轉檔比 stub 慢得多onnx→bie→nef 三 stage、bie 量化要跑 56 張 ref 圖)。
// 給足 deadlinestub 經驗幾秒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 跳過未設 %sPG DSN
本測試需要一個 migrate users / models PostgreSQL
- stage PG或本機 docker PGschema 來自 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(&reg); 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 字串。固定 UUIDdeterministic讓重跑時 idempotent
// email 帶可識別前綴避免撞真 user。
func ensureTestUser(t *testing.T, pool *pgxpool.Pool) string {
t.Helper()
// 固定 namespace UUIDv5 不需要;這裡直接寫死一個明顯是測試用的 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 idemail 用 functional unique indexlower(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 userupsert 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 storageNEF 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.PromoteToModelspromote真 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] InitJobmultipart %d bytesref_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 OKjob_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] 真轉檔未 completedstatus=%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測完軟刪除 modelpgRepo.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 OKmodel_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 %smodel 未真正落盤?):%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)
}
// FAAObjectKeyv0.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 #%dstatus=%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. fixtureonnx + 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 給 20mpoll deadline 內建 10m
// 測完會軟刪除建立的 modelt.Cleanuptest user 留在 PG無害、固定 UUID 可重用)。
// ==========================================================================

View 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` 都**不**編譯本檔(避免外部服務依賴污染主測試集)。
// - 本機通常連不到 stageVPN/網路),由 Orchestrator 對 stage 192.168.0.130 跑(見檔尾「給
// Orchestrator 對 stage 跑」段)。
//
// 測什麼mock 測不到的部分):直接用 visionA 的 ConverterClientconverter_client.go
// httptest mock打 stage 真轉檔服務,驗 visionA 端對「真服務實際回的格式」解析正確:
// 1. 連線 + 認證(真 API key 過認證)
// 2. InitJob 真送 onnx + 56 張 ref 圖multipart→ 拿 job_id / status=created / stage=onnx
// 3. GetJob poll 到 completedstub 模式幾秒)→ 解析真服務 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 三個 workeronnx/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_idstub 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 不存在:%serr=%v。"+
"用 %s 指定路徑。", onnxPath, err, realConvOnnxEnv)
}
if fi, err := os.Stat(imagesPath); err != nil || !fi.IsDir() {
t.Skipf("real-converter e2e 跳過ref images 目錄不存在:%serr=%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 bodyonnx + 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 fieldsconverter 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.pymodel_id=10
writeField("model_id", "9999")
// version 為字串、無型別限制;用簡單值對齊手動成功("v1")。
writeField("version", "v1")
writeField("platform", "520")
writeField("enable_evaluate", "false")
// model fileonnx
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 pollcontract 主路徑)
// ==========================================================================
// TestRealConverter_InitAndPollContract 驗 visionA ConverterClient 對真 stage 服務的主路徑:
//
// InitJob真送 onnx + 56 圖)→ status=created/running + stage=onnx
// → GetJob poll 到 completedstub 幾秒)
// → visionA 正確解析真服務 completed response即使 result_object_keys=null、無 analysis_info
// 也不報錯、不 panicInputShape/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 bytesref_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 透傳。亦容忍 runningrace
if cj.Status != "created" && cj.Status != "running" {
t.Errorf("InitJob status 預期 created/running得 %qjob=%+v", cj.Status, cj)
}
if cj.Stage != "onnx" {
t.Errorf("InitJob stage 預期 onnx得 %q", cj.Stage)
}
t.Logf("InitJob OKjob_id=%s status=%s stage=%s", cj.JobID, cj.Status, cj.Stage)
// ── GetJob poll 到 completed ───────────────────────────────────────
final := pollUntilTerminal(t, client, cj.JobID)
t.Logf("終態 jobstatus=%s stage=%q progress=%v input_filename=%q platform=%q",
final.Status, final.Stage, derefInt(final.Progress), final.SourceFilename, final.Platform)
// stub 模式預期 completed若 failed 也不該 paniccontract 仍須能解析)
if final.Status != "completed" {
t.Errorf("poll 終態預期 completedstub 模式幾秒完成),得 %qerror_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=%vstub 模式預期 nil"+
"若轉檔端已串 analysis_info 則此為正常)", final.InputShape)
}
if len(final.Classes) != 0 {
t.Logf("注意:真服務回了 classes=%vstub 模式預期空)", 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 #2promote 失敗路徑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 正確包裝成 ErrConverterUnavailablemapPromoteError 預設 5xx 分支)
//
// 這是有價值的 contract 測試:驗 visionA 對真服務 5xx 的錯誤處理鏈正確。
func TestRealConverter_PromoteFailsGracefullyOnStub(t *testing.T) {
env := requireRealConvEnv(t)
client := newRealConverterClient(t, env)
// 先 init + poll 到 completedpromote 前提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 未 completedstatus=%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記 logcontract 仍成立)
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)
}
// 關鍵 contractvisionA 回了「分類好的 error」而非 nil result + nil err不崩
if res != nil {
t.Errorf("Promote 失敗時 result 應為 nil得 %+v", res)
}
}
// ==========================================================================
// E2E #3GetResult 解析真 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 未 completedstatus=%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 bytescontent_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 bytesstub 預期回 15 bytes 佔位輸出)")
}
}
// ==========================================================================
// helpers
// ==========================================================================
func derefInt(p *int) int {
if p == nil {
return -1
}
return *p
}

View File

@ -0,0 +1,151 @@
//go:build dbtest
// redis_integration_test.go — RedisClient 對「真 Redis」的整合測試。
//
// owner: testing agentinternal/db redis 補測)
//
// build tag `dbtest`:預設 `go test ./...` 不編譯本檔。
// 執行方式(二擇一,對齊 internal/usersession/redis_integration_test.go 既有 pattern
//
// 1. 連既有 Redis例如 130 上的 visiona-redis
// VISIONA_TEST_REDIS_ADDR=visiona-redis:6379 go test -tags=dbtest ./internal/db/...
//
// 2. testcontainers 自動起一次性 Redis需 Docker daemon本機無 docker 時用
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true 指向 130
// go test -tags=dbtest ./internal/db/...
//
// 本檔與 redis_test.gominiredis預設可跑互補miniredis 驗連線/ping/close 程式邏輯,
// 本檔驗「NewRedisClient 對真 Redis 的 ping / graceful close / Client() 真的可用」。
package db
import (
"context"
"net"
"os"
"strconv"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"visiona-backend/internal/config"
)
// realRedisAddr 回傳一個可連的真 Redis host:port
// - 若設了 VISIONA_TEST_REDIS_ADDR → 直接用130 補跑路徑)。
// - 否則 → testcontainers 起一次性 redis:7-alpine。
func realRedisAddr(t *testing.T) string {
t.Helper()
if addr := os.Getenv("VISIONA_TEST_REDIS_ADDR"); addr != "" {
return addr
}
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForListeningPort("6379/tcp").WithStartupTimeout(60 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("start redis container: %v", err)
}
t.Cleanup(func() { _ = container.Terminate(ctx) })
host, err := container.Host(ctx)
if err != nil {
t.Fatalf("container host: %v", err)
}
port, err := container.MappedPort(ctx, "6379/tcp")
if err != nil {
t.Fatalf("container port: %v", err)
}
return host + ":" + port.Port()
}
// realRedisConfig 把 host:port 拆回 RedisConfig 餵 NewRedisClient。
func realRedisConfig(t *testing.T, addr string) config.RedisConfig {
t.Helper()
host, portStr, err := net.SplitHostPort(addr)
require.NoError(t, err, "解析 real redis addr=%s", addr)
port, err := strconv.Atoi(portStr)
require.NoError(t, err)
return config.RedisConfig{
Host: host,
Port: port,
Password: os.Getenv("VISIONA_TEST_REDIS_PASSWORD"),
DB: 0,
ConnTimeout: 5 * time.Second,
}
}
// TestRealRedis_NewClientConnectsAndPings 驗證 NewRedisClient 對真 Redis
// 建連成功、啟動 ping 通過、Client() 可實際操作、Ping() 可重複呼叫。
func TestRealRedis_NewClientConnectsAndPings(t *testing.T) {
cfg := realRedisConfig(t, realRedisAddr(t))
rc, err := NewRedisClient(context.Background(), cfg, nil)
require.NoError(t, err, "真 Redis 可達時應成功建連")
require.NotNil(t, rc)
t.Cleanup(rc.Close)
// Client() 可實際操作真 RedisSET/GET round-trip
ctx := context.Background()
require.NoError(t, rc.Client().Set(ctx, "db_redis_it_key", "v1", time.Minute).Err())
got, err := rc.Client().Get(ctx, "db_redis_it_key").Result()
require.NoError(t, err)
assert.Equal(t, "v1", got)
t.Cleanup(func() { _ = rc.Client().Del(context.Background(), "db_redis_it_key").Err() })
// Ping 可重複呼叫health check 場景)。
require.NoError(t, rc.Ping(ctx))
require.NoError(t, rc.Ping(ctx))
}
// TestRealRedis_GracefulClose 驗證 Close 後底層 client 不可再用graceful close 生效)。
func TestRealRedis_GracefulClose(t *testing.T) {
cfg := realRedisConfig(t, realRedisAddr(t))
rc, err := NewRedisClient(context.Background(), cfg, nil)
require.NoError(t, err)
rc.Close()
// Close 後 Ping 應失敗go-redis 回 "client is closed")。
assert.Error(t, rc.Ping(context.Background()), "graceful close 後 Ping 應失敗")
}
// TestRealRedis_WrongDBStillConnects 驗證非 0 的 DB index 也能正常建連DB 欄位有被套用)。
func TestRealRedis_WrongDBStillConnects(t *testing.T) {
cfg := realRedisConfig(t, realRedisAddr(t))
cfg.DB = 1 // 切到 db1
rc, err := NewRedisClient(context.Background(), cfg, nil)
require.NoError(t, err)
t.Cleanup(rc.Close)
// 在 db1 寫一筆,確認 SELECT 1 真的生效(不會誤落 db0
ctx := context.Background()
require.NoError(t, rc.Client().Set(ctx, "db1_only_key", "x", time.Minute).Err())
n, err := rc.Client().Exists(ctx, "db1_only_key").Result()
require.NoError(t, err)
assert.Equal(t, int64(1), n)
t.Cleanup(func() { _ = rc.Client().Del(context.Background(), "db1_only_key").Err() })
// 另開一個 db0 client確認 db1 的 key 不可見DB 隔離正確)。
db0 := redis.NewClient(&redis.Options{
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
Password: cfg.Password,
DB: 0,
})
t.Cleanup(func() { _ = db0.Close() })
n0, err := db0.Exists(ctx, "db1_only_key").Result()
require.NoError(t, err)
assert.Equal(t, int64(0), n0, "db1 的 key 不應在 db0 可見DB 隔離)")
}

View File

@ -0,0 +1,187 @@
// redis_test.go — RedisClient 的本機可跑測試(不需 docker
//
// owner: testing agentinternal/db redis 補測)
//
// 分類:
// - SafeRedisTarget純函式 unitlog 安全字串、不含密碼)
// - NewRedisClient / Ping / Close / Client用 in-process miniredis純 Go不需 docker
// 驗證連線建立、啟動 ping、graceful close、底層 client 取得。
// - fail-fast連不上的 host 應立即回 errorNewRedisClient 不靜默 fallback
//
// 與 redis_integration_test.go//go:build dbtest真 Redis互補
// 本檔用 miniredis 驗「連線/ping/close 的程式邏輯」dbtest 驗「真 Redis parity」。
package db
import (
"context"
"net"
"strconv"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/config"
)
// miniredisConfig 起一個 in-process miniredis 並回傳對應的 RedisConfig
// (把 mr.Addr() 的 host:port 拆回 Host / Port 欄位,餵給 NewRedisClient
func miniredisConfig(t *testing.T) config.RedisConfig {
t.Helper()
mr := miniredis.RunT(t) // RunT 會在 t.Cleanup 自動 Close
host, portStr, err := net.SplitHostPort(mr.Addr())
require.NoError(t, err, "解析 miniredis addr")
port, err := strconv.Atoi(portStr)
require.NoError(t, err, "解析 miniredis port")
return config.RedisConfig{
Host: host,
Port: port,
DB: 0,
ConnTimeout: 2 * time.Second,
}
}
// ---------------------------------------------------------------------------
// SafeRedisTarget — 純函式 unithappy / 預設值 / 不含密碼)
// ---------------------------------------------------------------------------
func TestSafeRedisTarget_FormatsHostPortDB(t *testing.T) {
got := SafeRedisTarget(config.RedisConfig{Host: "10.0.0.5", Port: 6380, DB: 3})
assert.Equal(t, "10.0.0.5:6380/3", got)
}
func TestSafeRedisTarget_DefaultsPortWhenZero(t *testing.T) {
// 邊界值Port=0 應回退預設 6379對齊 NewRedisClient 行為)。
got := SafeRedisTarget(config.RedisConfig{Host: "redis", Port: 0, DB: 0})
assert.Equal(t, "redis:6379/0", got)
}
func TestSafeRedisTarget_NeverContainsPassword(t *testing.T) {
// 安全:即使 config 帶密碼SafeRedisTarget 也不得洩漏。
cfg := config.RedisConfig{Host: "redis", Port: 6379, DB: 0, Password: "super-secret-pw"}
got := SafeRedisTarget(cfg)
assert.NotContains(t, got, "super-secret-pw", "log 安全字串不得含密碼")
assert.Equal(t, "redis:6379/0", got)
}
func TestSafeRedisTarget_EmptyHost(t *testing.T) {
// 空 Host未啟用 Redis仍回固定格式不 panic。
got := SafeRedisTarget(config.RedisConfig{})
assert.Equal(t, ":6379/0", got)
}
// ---------------------------------------------------------------------------
// NewRedisClient — happy pathminiredis
// ---------------------------------------------------------------------------
func TestNewRedisClient_ConnectsAndPings(t *testing.T) {
cfg := miniredisConfig(t)
rc, err := NewRedisClient(context.Background(), cfg, nil)
require.NoError(t, err, "miniredis 可達時應成功建連並 ping 通過")
require.NotNil(t, rc)
t.Cleanup(rc.Close)
// 底層 client 可取得且可用
require.NotNil(t, rc.Client(), "Client() 應回傳非 nil 底層 client")
// Ping 應通過
require.NoError(t, rc.Ping(context.Background()), "已連線的 client Ping 應成功")
}
func TestNewRedisClient_DefaultsPortWhenZero(t *testing.T) {
// Port=0 路徑NewRedisClient 應回退 6379。
// 這裡無法用 miniredis它是隨機 port改用「連不上的 host」驗證
// fail-fast 與 SafeRedisTarget 中的預設 port 一致呈現。
cfg := config.RedisConfig{
Host: "192.0.2.1", // TEST-NET-1保證不可路由
Port: 0,
ConnTimeout: 300 * time.Millisecond,
}
_, err := NewRedisClient(context.Background(), cfg, nil)
require.Error(t, err, "連不上應 fail-fast")
assert.Contains(t, err.Error(), "192.0.2.1:6379/0",
"error 應含 SafeRedisTarget含預設 port 6379")
}
// ---------------------------------------------------------------------------
// NewRedisClient — error pathfail-fast不靜默 fallback
// ---------------------------------------------------------------------------
func TestNewRedisClient_UnreachableHostFailsFast(t *testing.T) {
cfg := config.RedisConfig{
Host: "192.0.2.1", // TEST-NET-1RFC 5737保證 dial 超時
Port: 6379,
ConnTimeout: 300 * time.Millisecond, // 短逾時讓測試快速失敗
}
start := time.Now()
rc, err := NewRedisClient(context.Background(), cfg, nil)
elapsed := time.Since(start)
require.Error(t, err, "Redis 連不上時 NewRedisClient 必須回 errorfail-fast")
assert.Nil(t, rc, "失敗時不應回傳半成品 client")
assert.Contains(t, err.Error(), "redis ping failed", "error 應標明 ping 失敗")
// fail-fast不應 hang 太久(給予 ConnTimeout 數倍寬容,避免 CI 抖動 flaky
assert.Less(t, elapsed, 5*time.Second, "fail-fast 不應 hang實際耗時 %v", elapsed)
}
func TestNewRedisClient_ContextCancelledFailsFast(t *testing.T) {
cfg := config.RedisConfig{
Host: "192.0.2.1",
Port: 6379,
ConnTimeout: 5 * time.Second,
}
// 已取消的 contextping 應立即失敗,不等滿 ConnTimeout。
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
_, err := NewRedisClient(ctx, cfg, nil)
elapsed := time.Since(start)
require.Error(t, err, "已取消的 ctx 下 NewRedisClient 應回 error")
assert.Less(t, elapsed, 2*time.Second, "已取消 ctx 不應等滿 ConnTimeout實際 %v", elapsed)
}
// ---------------------------------------------------------------------------
// Close — graceful + 重複呼叫安全 + nil receiver
// ---------------------------------------------------------------------------
func TestRedisClient_Close_Idempotent(t *testing.T) {
cfg := miniredisConfig(t)
rc, err := NewRedisClient(context.Background(), cfg, nil)
require.NoError(t, err)
// 重複呼叫 Close 不應 panicdoc 承諾「重複呼叫安全」)。
assert.NotPanics(t, func() {
rc.Close()
rc.Close()
})
}
func TestRedisClient_Close_NilReceiverSafe(t *testing.T) {
// 邊界值nil receiver Close 不應 panic防呆main.go 失敗路徑可能傳 nil
var rc *RedisClient
assert.NotPanics(t, func() { rc.Close() })
}
// ---------------------------------------------------------------------------
// Ping — 已關閉 client 的行為
// ---------------------------------------------------------------------------
func TestRedisClient_Ping_AfterCloseReturnsError(t *testing.T) {
cfg := miniredisConfig(t)
rc, err := NewRedisClient(context.Background(), cfg, nil)
require.NoError(t, err)
rc.Close()
// Close 後底層 client 已關Ping 應回 errorgo-redis: client is closed
err = rc.Ping(context.Background())
assert.Error(t, err, "已關閉的 client Ping 應回 error")
}

View File

@ -80,6 +80,10 @@ func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) (
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
//
// pgx.Tx 是 interface無法直接取 (pgx.Tx)(nil) 當靜態斷言對象nil interface 沒有具體型別),
// 故只對 *pgxpool.Pool 做編譯期斷言pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。
var _ Querier = (*pgxpool.Pool)(nil)
// 兩者都顯式斷言,避免任一方未來簽章漂移時只在 runtime 才爆。
// pgx.Tx 本身是 interfacetyped-nil(pgx.Tx)(nil))可作為靜態斷言對象——編譯器只看
// 靜態型別是否滿足 Querier、不解參考該 nil 值,故安全。
var (
_ Querier = (*pgxpool.Pool)(nil)
_ Querier = (pgx.Tx)(nil)
)

View File

@ -174,6 +174,11 @@ func (r *InMemoryRepository) List(ctx context.Context, ownerUserID string) ([]*D
}
// Save 新增或更新 deviceupsert by ID
//
// remote_status / status 補預設值offline / unknown與 PostgresRepository.Save 一致:
// devices 表的這兩個欄位是 NOT NULL DEFAULT 'offline' / 'unknown'PG Save 對空值補預設後寫入。
// in-memory 在此同樣補預設,避免「同一筆空狀態 device 經 PG 讀出 offline/unknown、
// 經 in-memory 讀出空字串」的隱性落差(前端顯示 RemoteStatus見 api/devices.go
func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
if d == nil || d.ID == "" {
return errors.New("device: Save requires non-nil device with ID")
@ -184,6 +189,13 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
now := time.Now().UTC()
// Copy 避免外部後續修改影響 store
cp := *d
// 補預設值,對齊 PG NOT NULL DEFAULT 欄位語意(見上方說明)。
if cp.RemoteStatus == "" {
cp.RemoteStatus = RemoteStatusOffline
}
if cp.Status == "" {
cp.Status = USBStatusUnknown
}
if existing, ok := r.devices[d.ID]; ok && existing.DeletedAt == nil {
cp.CreatedAt = existing.CreatedAt // 保留原始 CreatedAt
} else if cp.CreatedAt.IsZero() {

View File

@ -78,7 +78,27 @@ func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error
// GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。
//
// 對齊 in-memory同一個 serial 在不同 owner 下不互相干擾owner 過濾)。
//
// serial 空字串Save 把空 serial 寫成 SQL NULL見 SaveTx故空 serial 查詢需用
// serial_number IS NULL 比對(等號比較永不命中 NULL。非空 serial 走參數化 = $2。
// 此分支讓 PG 與 in-memoryd.SerialNumber == serial空查空語意一致。
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
if serial == "" {
const qNull = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND serial_number IS NULL AND deleted_at IS NULL`
row := r.pool.QueryRow(ctx, qNull, ownerUserID)
d, err := scanDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("device: pg GetBySerial: %w", err)
}
return d, nil
}
const q = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
@ -133,11 +153,23 @@ func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*D
// 重註冊(已 soft-delete 的 serial走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT
// partial unique 因舊列已 deleted 不阻擋(見 package 註解)。
func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
return r.SaveTx(ctx, r.pool, d)
}
// SaveTx 與 Save 相同的 upsert 語意,但在傳入的 Querierpool 或 tx上執行。
//
// 用於 pairing exchange 自建 device 時,與 session token 建立在同一交易內pairing 收尾問題 #2
// 「建 device + 建 session token」整筆原子——任一步失敗整筆 rollback避免建了 device 卻沒建成
// session token 的中間態。q 可為 *pgxpool.Pool自動 commit或 pgx.Tx隨外層交易
func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device) error {
if d == nil || d.ID == "" {
return errors.New("device: Save requires non-nil device with ID")
}
// remote_status / status 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。
// remote_status / status 補預設值offline / unknown避免空字串寫進「有預設」的
// NOT NULL 欄位devices.remote_status / status 為 NOT NULL DEFAULT後語意混淆。
// 此補預設行為與 InMemoryRepository.Save 一致(見 device.go兩個實作對空狀態的
// 處理刻意對齊,讓同一筆空狀態 device 不論走 PG 或 in-memory 都讀回 offline / unknown。
remoteStatus := d.RemoteStatus
if remoteStatus == "" {
remoteStatus = RemoteStatusOffline
@ -153,7 +185,25 @@ func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
createdAt = d.CreatedAt.UTC()
} // else: 留 nil → COALESCE($n, now())
const q = `
// serial_number空字串寫成 SQL NULL而非 '')。
//
// 為什麼devices.serial_number 是 nullable TEXTpartial unique index
// uq_devices_owner_serial_active (owner_user_id, serial_number) WHERE deleted_at IS NULL
// 對「非 NULL」值才強制唯一。SQL 規範下每個 NULL 互不相等,故多筆「無序號」的
// device同 owner不互撞 unique但空字串 '' 是個確定值,同 owner 多筆 '' 會撞。
//
// 語意:實體裝置一定帶非空 serial防重複註冊照舊生效雲端 pairing exchange
// 自建的 device 沒有真實序號,正確表示是「無序號」(NULL) 而非 ''。如此同一 owner
// 多次 exchange 各建一筆 serial=NULL 的 distinct device、不撞 unique。
//
// 未來 local-tool 上報真實 serial 時,直接把這個 NULL 更新成真值即可,無 reconciliation
// 成本(若先前捏一個假 serial 佔位,反而要額外處理覆蓋)。
var serialNumber any
if d.SerialNumber != "" {
serialNumber = d.SerialNumber
} // else: 留 nil → 寫入 SQL NULL
const sql = `
INSERT INTO devices (
id, owner_user_id, name, device_type, serial_number,
remote_status, last_seen_at, last_connected_at, status,
@ -181,12 +231,12 @@ func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
paired_at = EXCLUDED.paired_at,
deleted_at = EXCLUDED.deleted_at`
_, err := r.pool.Exec(ctx, q,
_, err := q.Exec(ctx, sql,
d.ID, // $1
d.OwnerUserID, // $2
d.Name, // $3
d.DeviceType, // $4
d.SerialNumber, // $5
serialNumber, // $5
remoteStatus, // $6
d.LastSeenAt, // $7
d.LastConnectedAt, // $8

View File

@ -360,6 +360,10 @@ func TestPG_ConcurrentRegisterSameSerial(t *testing.T) {
}
wg.Wait()
// 注意errs 收集在各 goroutine只寫各自 index、無共享寫衝突但所有斷言
// 都在 Wait 後的主 goroutine 進行。require.* 會走 t.FailNow → runtime.Goexit
// 只能在測試主 goroutine 呼叫;若放進上面的 spawn goroutine 會是未定義行為。
// 這是 Reviewer 標示的易退化點 —— 保持 require.ErrorAs 留在此迴圈Wait 後、主 goroutine
var ok, conflict int
for _, e := range errs {
if e == nil {

View File

@ -121,6 +121,31 @@ func TestPG_List_Filter(t *testing.T) {
assert.Equal(t, id1, list[0].ID)
}
// List 排序:宣稱 ORDER BY created_at DESC最新在前。建多筆不同 created_at、驗回傳順序。
func TestPG_List_OrderByCreatedAtDesc(t *testing.T) {
ctx := context.Background()
r, _, owner := newPGRepo(t)
base := time.Now().UTC().Truncate(time.Microsecond)
// 刻意亂序插入mid → oldest → newest以證明排序來自查詢而非插入順序。
idMid := uuid.NewString()
idOld := uuid.NewString()
idNew := uuid.NewString()
require.NoError(t, r.Save(ctx, &Model{ID: idMid, OwnerUserID: owner, Name: "mid", StorageKey: "k", Source: SourceUploaded, CreatedAt: base.Add(-2 * time.Hour)}))
require.NoError(t, r.Save(ctx, &Model{ID: idOld, OwnerUserID: owner, Name: "old", StorageKey: "k", Source: SourceUploaded, CreatedAt: base.Add(-4 * time.Hour)}))
require.NoError(t, r.Save(ctx, &Model{ID: idNew, OwnerUserID: owner, Name: "new", StorageKey: "k", Source: SourceUploaded, CreatedAt: base}))
list, err := r.List(ctx, ListFilter{OwnerUserID: owner})
require.NoError(t, err)
require.Len(t, list, 3)
// 最新在前DESCnew → mid → old。
assert.Equal(t, []string{idNew, idMid, idOld}, []string{list[0].ID, list[1].ID, list[2].ID},
"List 應依 created_at DESC最新在前排序")
// 額外以時間單調遞減交叉驗證,避免只靠 ID 對齊。
assert.True(t, list[0].CreatedAt.After(list[1].CreatedAt), "list[0] 應較 list[1] 新")
assert.True(t, list[1].CreatedAt.After(list[2].CreatedAt), "list[1] 應較 list[2] 新")
}
func TestPG_Delete_SoftDelete(t *testing.T) {
ctx := context.Background()
r, _, owner := newPGRepo(t)
@ -217,7 +242,8 @@ func TestPG_Upsert_PreservesCreatedAt(t *testing.T) {
assert.Equal(t, "v2", second.Name)
assert.Equal(t, "k2", second.StorageKey)
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值")
assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進")
// 已 sleep 10msupdated_at 必嚴格推進(嚴格 After非 After-OR-Equal
assert.True(t, second.UpdatedAt.After(first.UpdatedAt), "updated_at 應嚴格推進sleep 10ms 後)")
}
// soft-delete 後再 Save 同 ID復活應採用新 created_at非保留已刪除的舊值

View File

@ -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 標準參數,由 callerOIDC discovery 不一定
// 在 prompt_values_supported 列出,但 Core 標準參數 IdP 多半支援)。設成 config 可控
// 是為了讓「強制重新認證」在不同部署間可開可關 — 例如 stage 要每次都問、prod 視 UX 決定。
// 對齊 VISIONA_OIDC_PROMPT_LOGIN。
PromptLogin bool
}
// DefaultScopes 是 OIDC 標準 scope 集合,能取得 sub / email / name 三個 claim。

View File

@ -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。

View File

@ -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) {

View File

@ -0,0 +1,164 @@
package relay
import (
"context"
"net"
"testing"
"time"
"github.com/hashicorp/yamux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/session"
)
// owner: testing agentinternal/relay local_handle 補測)
//
// 測試策略LocalHandle 包住 *yamux.Session。我們不需要真實 WebSocket / 真實 tunnel —
// yamux 可直接跑在 net.Pipe() 之上in-memory、純 Go、不需 docker
// 這已足以驗證 LocalHandle 的「session 開 stream / 關閉 / IsClosed」邏輯。
// 真正端對端的 yamux-over-WebSocket 由 relay 的 integration_test.go 涵蓋。
// newYamuxPair 用 net.Pipe 建一對 yamux sessionserver 給 LocalHandleclient 端配合)。
// 回傳 server sessionLocalHandle 用)+ client session驅動 Accept避免 Open 卡住)。
func newYamuxPair(t *testing.T) (server, client *yamux.Session) {
t.Helper()
c1, c2 := net.Pipe()
srv, err := yamux.Server(c1, yamux.DefaultConfig())
require.NoError(t, err, "yamux.Server")
cli, err := yamux.Client(c2, yamux.DefaultConfig())
require.NoError(t, err, "yamux.Client")
t.Cleanup(func() {
_ = srv.Close()
_ = cli.Close()
_ = c1.Close()
_ = c2.Close()
})
return srv, cli
}
// ---------------------------------------------------------------------------
// Summary / RecordHeartbeat — 純邏輯mutex-protected不碰 yamux
// ---------------------------------------------------------------------------
func TestLocalHandle_Summary_ReflectsConstructorArgs(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "203.0.113.7:54321")
sum := h.Summary()
require.NotNil(t, sum)
assert.Equal(t, "vAc_tok", sum.Token)
assert.Equal(t, "203.0.113.7:54321", sum.RemoteAddr)
assert.False(t, sum.ConnectedAt.IsZero(), "ConnectedAt 應已設定")
assert.False(t, sum.LastHeartbeat.IsZero(), "LastHeartbeat 應已設定")
// ConnectedAt 與 LastHeartbeat 初始相同
assert.Equal(t, sum.ConnectedAt, sum.LastHeartbeat)
}
func TestLocalHandle_Summary_ReturnsCopy(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
// caller 改 snapshot 不應影響內部狀態(防中間態觀察 / 並發寫)。
s1 := h.Summary()
s1.Token = "MUTATED"
s1.LastHeartbeat = time.Unix(0, 0)
s2 := h.Summary()
assert.Equal(t, "vAc_tok", s2.Token, "Summary() 必須回副本caller 修改不得污染內部")
assert.NotEqual(t, time.Unix(0, 0), s2.LastHeartbeat)
}
func TestLocalHandle_RecordHeartbeat_UpdatesLastHeartbeat(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
before := h.Summary().LastHeartbeat
newTime := before.Add(30 * time.Second)
h.RecordHeartbeat(newTime)
after := h.Summary().LastHeartbeat
assert.True(t, after.Equal(newTime), "RecordHeartbeat 應更新 LastHeartbeatwant %v got %v", newTime, after)
// ConnectedAt 不應被動到
assert.Equal(t, before, h.Summary().ConnectedAt)
}
// ---------------------------------------------------------------------------
// OpenStream / IsClosed / Close — 跑在 net.Pipe 上的真 yamuxin-memory
// ---------------------------------------------------------------------------
func TestLocalHandle_OpenStream_Succeeds(t *testing.T) {
srv, cli := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
// client 端需要 Accept 才能讓 server 端 Open 完成 handshake。
acceptDone := make(chan net.Conn, 1)
go func() {
stream, err := cli.Accept()
if err == nil {
acceptDone <- stream
} else {
acceptDone <- nil
}
}()
conn, err := h.OpenStream(context.Background())
require.NoError(t, err, "session 健在時 OpenStream 應成功")
require.NotNil(t, conn)
t.Cleanup(func() { _ = conn.Close() })
select {
case s := <-acceptDone:
require.NotNil(t, s, "client 端應 Accept 到對應 stream")
_ = s.Close()
case <-time.After(2 * time.Second):
t.Fatal("client 端未在 2s 內 Accept 到 stream")
}
}
func TestLocalHandle_IsClosed_FalseWhenLive(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
assert.False(t, h.IsClosed(), "活的 session IsClosed 應為 false")
}
func TestLocalHandle_Close_MarksClosed(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
require.NoError(t, h.Close())
assert.True(t, h.IsClosed(), "Close 後 IsClosed 應為 true")
}
// TestLocalHandle_OpenStream_AfterClose 驗證 close 後 OpenStream 回 ErrSessionClosed。
func TestLocalHandle_OpenStream_AfterClose(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
require.NoError(t, h.Close())
_, err := h.OpenStream(context.Background())
assert.ErrorIs(t, err, session.ErrSessionClosed, "已關閉的 session OpenStream 應回 ErrSessionClosed")
}
// TestLocalHandle_OpenStream_CtxCancelled 驗證 ctx 已取消時 OpenStream 早退回 ctx.Err()。
func TestLocalHandle_OpenStream_CtxCancelled(t *testing.T) {
srv, _ := newYamuxPair(t)
h := NewLocalHandle(srv, "vAc_tok", "addr")
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立即取消
_, err := h.OpenStream(ctx)
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled, "ctx 取消時 OpenStream 應回 ctx.Err()")
}
// TestLocalHandle_ImplementsHandle 編譯期 + 執行期確認介面一致。
func TestLocalHandle_ImplementsHandle(t *testing.T) {
srv, _ := newYamuxPair(t)
var _ session.Handle = NewLocalHandle(srv, "t", "a")
}

View File

@ -189,6 +189,38 @@ func TestInMemoryStore_List(t *testing.T) {
assert.True(t, tokens["b"])
}
// TestInMemoryStore_List_Empty 驗證空 store 回空 slice不 nil-panic、不回 error
func TestInMemoryStore_List_Empty(t *testing.T) {
s := NewInMemoryStore()
summaries, err := s.List(context.Background())
require.NoError(t, err)
assert.Empty(t, summaries)
}
// TestInMemoryStore_List_ReturnsCopies 驗證 List 回傳的是 Summary 副本:
// caller 改回傳值不得污染 handle 內部狀態inmemory_store.go 註解承諾「複製 Summary」
func TestInMemoryStore_List_ReturnsCopies(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
h := newFakeHandle("a", "u1", "d1")
require.NoError(t, s.Register(ctx, "a", h))
first, err := s.List(ctx)
require.NoError(t, err)
require.Len(t, first, 1)
// 污染 caller 拿到的副本
first[0].Token = "HACKED"
first[0].UserID = "HACKED"
// 再查一次,內部 handle 的 Summary 不應被改到
second, err := s.List(ctx)
require.NoError(t, err)
require.Len(t, second, 1)
assert.Equal(t, "a", second[0].Token, "List 必須回副本caller 修改不得污染內部")
assert.Equal(t, "u1", second[0].UserID)
}
func TestInMemoryStore_CleanupExpired(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()

View File

@ -0,0 +1,81 @@
// InMemoryStore 的單元測試DB-on FK 收尾,問題 #1
//
// 不帶 build tag屬於預設 `go test ./...` 範圍(無需 Docker
// 驗證 Upsert / Get 的核心語意,並與 postgres_store_db_test.go 的 dbtest 對齊行為。
package user
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInMemoryStore_UpsertAndGet(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
in := &User{ID: "sub-001", Email: "alice@example.com", Name: "Alice", Roles: []string{"admin"}}
require.NoError(t, s.Upsert(ctx, in))
got, err := s.Get(ctx, "sub-001")
require.NoError(t, err)
assert.Equal(t, "sub-001", got.ID)
assert.Equal(t, "alice@example.com", got.Email)
assert.Equal(t, "Alice", got.Name)
assert.Equal(t, []string{"admin"}, got.Roles)
assert.False(t, got.CreatedAt.IsZero(), "CreatedAt 應自動填入")
assert.False(t, got.UpdatedAt.IsZero(), "UpdatedAt 應自動填入")
}
func TestInMemoryStore_Upsert_UpdatesAndPreservesCreatedAt(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
require.NoError(t, s.Upsert(ctx, &User{ID: "sub-002", Email: "old@example.com", Name: "Old"}))
first, err := s.Get(ctx, "sub-002")
require.NoError(t, err)
origCreated := first.CreatedAt
// 同 ID 再 upsert更新 email/name保留 created_at。
require.NoError(t, s.Upsert(ctx, &User{ID: "sub-002", Email: "new@example.com", Name: "New"}))
second, err := s.Get(ctx, "sub-002")
require.NoError(t, err)
assert.Equal(t, "new@example.com", second.Email, "email 應更新")
assert.Equal(t, "New", second.Name, "name 應更新")
assert.Equal(t, origCreated, second.CreatedAt, "created_at 應保留")
}
func TestInMemoryStore_Get_NotFound(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
_, err := s.Get(ctx, "nope")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestInMemoryStore_Upsert_RequiresIDAndEmail(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
assert.Error(t, s.Upsert(ctx, &User{ID: "", Email: "x@y.z"}), "缺 ID 應回錯")
assert.Error(t, s.Upsert(ctx, &User{ID: "sub", Email: ""}), "缺 email 應回錯users.email NOT NULL")
assert.Error(t, s.Upsert(ctx, nil), "nil 應回錯")
}
// TestInMemoryStore_Upsert_RolesCopied 確認 Upsert/Get 對 roles 做 copy
// 外部後續修改傳入 slice 不影響 store 內容(對齊 in-memory copy 語意)。
func TestInMemoryStore_Upsert_RolesCopied(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
roles := []string{"a", "b"}
require.NoError(t, s.Upsert(ctx, &User{ID: "sub-003", Email: "c@d.e", Roles: roles}))
roles[0] = "MUTATED" // 外部改傳入 slice
got, err := s.Get(ctx, "sub-003")
require.NoError(t, err)
assert.Equal(t, []string{"a", "b"}, got.Roles, "store 內 roles 不應被外部 mutation 影響")
}

View File

@ -0,0 +1,149 @@
// Package user 的 Postgres 持久層實作DB-on FK 收尾,問題 #1
//
// PostgresStore 實作與 InMemoryStore 完全相同的 Store interface讓 main.go 在 dbPool != nil
// 時無痛切換、OIDC callback 呼叫端一行都不需改。
//
// 對齊:
// - migrations/0001_create_users_models.up.sqlusers 表id UUID PK、email NOT NULL +
// uq_users_email_lower、name、roles TEXT[] NOT NULL DEFAULT '{}'、created_at/updated_at/deleted_at
//
// pattern 比照 internal/model/postgres_repository.go、internal/device/postgres_repository.go
// - 參數化 SQL無字串拼接使用者輸入
// - upsert by idON CONFLICT (id) DO UPDATE保留 created_at
// - scan helper、nullable 欄位以指標接、NULL → 空字串
//
// email 唯一衝突語意D1-B
//
// upsert 以 idOIDC sub為衝突依據。若兩個不同 sub 共用同 email會撞 uq_users_email_lower
// lower(email) 唯一)→ INSERT/UPDATE 回 23505。雛形視為資料異常、原樣回錯不靜默吞
// 由 callerOIDC callbacklog 後回 500。實務上同一 IdP 下 sub↔email 一對一,極少發生。
package user
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// PostgresStore 是 User 的 PostgreSQL 持久層實作。
type PostgresStore struct {
pool *pgxpool.Pool
}
// NewPostgresStore 建立一個以 pgxpool 為後端的 Store。
//
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore {
return &PostgresStore{pool: pool}
}
// 編譯時檢查:確保 PostgresStore 實作 Store。
var _ Store = (*PostgresStore)(nil)
// userColumns 是 SELECT 共用欄位清單(順序必須與 scanUser 對齊)。
const userColumns = `id, email, name, roles, created_at, updated_at, deleted_at`
// Upsert 新增或更新 userupsert by id
//
// 既存ON CONFLICT (id))→ 更新 email/name/roles + updated_at保留 created_at
// 不存在 → 新建created_at = now()。roles 為 nil 時寫空陣列(對齊 NOT NULL DEFAULT '{}')。
func (s *PostgresStore) Upsert(ctx context.Context, in *User) error {
if in == nil || in.ID == "" {
return errors.New("user: Upsert requires non-nil user with ID")
}
if in.Email == "" {
return errors.New("user: Upsert requires non-empty email")
}
// name nullable空字串寫 NULL對齊 in-memory zero value 與 scan NULL → 空字串)。
var nameArg any
if in.Name != "" {
nameArg = in.Name
}
// roles NOT NULL DEFAULT '{}'nil 時寫空陣列。
roles := in.Roles
if roles == nil {
roles = []string{}
}
const q = `
INSERT INTO users (id, email, name, roles, created_at, updated_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = EXCLUDED.name,
roles = EXCLUDED.roles,
updated_at = now()
-- created_at 不在 UPDATE SET保留原值首次 INSERT now()
-- deleted_at 不觸碰upsert 不會復活已軟刪 user雛形無刪 user 路徑保守不動`
if _, err := s.pool.Exec(ctx, q, in.ID, in.Email, nameArg, roles); err != nil {
return fmt.Errorf("user: pg Upsert: %w", err)
}
return nil
}
// Get 取得單一 user不存在或已軟刪除回 ErrNotFound。
func (s *PostgresStore) Get(ctx context.Context, id string) (*User, error) {
const q = `SELECT ` + userColumns + `
FROM users
WHERE id = $1 AND deleted_at IS NULL`
row := s.pool.QueryRow(ctx, q, id)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("user: pg Get: %w", err)
}
return u, nil
}
// ==========================================================================
// scan helper
// ==========================================================================
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面。
type rowScanner interface {
Scan(dest ...any) error
}
// scanUser 從一列掃出 *User。欄位順序必須與 userColumns 對齊。
//
// name nullable → 以 *string 接、NULL 掃成空字串(對齊 in-memory zero value
// roles TEXT[] → []stringpgx decode。時間欄位正規化為 UTC。
func scanUser(row rowScanner) (*User, error) {
var (
u User
name *string
)
err := row.Scan(
&u.ID,
&u.Email,
&name,
&u.Roles,
&u.CreatedAt,
&u.UpdatedAt,
&u.DeletedAt,
)
if err != nil {
return nil, err
}
if name != nil {
u.Name = *name
}
u.CreatedAt = u.CreatedAt.UTC()
u.UpdatedAt = u.UpdatedAt.UTC()
if u.DeletedAt != nil {
d := u.DeletedAt.UTC()
u.DeletedAt = &d
}
return &u, nil
}

View File

@ -0,0 +1,138 @@
//go:build dbtest
// PostgresStoreuser的真 DB 整合測試DB-on FK 收尾,問題 #1
//
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers
// 預設 `go test ./...`(無 Docker不會觸碰本檔維持綠燈。
//
// 執行:
//
// go test -tags=dbtest ./internal/user/...
// # 無本機 Docker 時Orchestrator 在 130 補跑:
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest ./internal/user/...
//
// 涵蓋:
// - Upsert 新建 + Get round-tripid=sub UUID、email、name、roles
// - Upsert 同 id 再寫:更新 email/name/roles、保留 created_at
// - email 大小寫不敏感唯一uq_users_email_lower兩個不同 sub 共用同 email差大小寫→ 撞 unique
// - Get 不存在 → ErrNotFound
// - roles nil → 寫空陣列NOT NULL DEFAULT '{}'round-trip 回空 slice
package user
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/db/testsupport"
)
// newPGUserStore 啟動一次性測試 DB、truncate users回傳 store。
func newPGUserStore(t *testing.T) (*PostgresStore, *testsupport.TestDB) {
t.Helper()
tdb := testsupport.SetupTestDB(t)
tdb.Truncate(t, "users")
return NewPostgresStore(tdb.Pool), tdb
}
func TestPGUser_UpsertAndGet(t *testing.T) {
ctx := context.Background()
s, _ := newPGUserStore(t)
id := uuid.NewString() // 模擬 OIDC subUUID 格式D1-B
in := &User{ID: id, Email: "Alice@Example.com", Name: "Alice", Roles: []string{"admin", "user"}}
require.NoError(t, s.Upsert(ctx, in))
got, err := s.Get(ctx, id)
require.NoError(t, err)
assert.Equal(t, id, got.ID)
assert.Equal(t, "Alice@Example.com", got.Email)
assert.Equal(t, "Alice", got.Name)
assert.Equal(t, []string{"admin", "user"}, got.Roles)
assert.False(t, got.CreatedAt.IsZero())
assert.False(t, got.UpdatedAt.IsZero())
}
func TestPGUser_Upsert_UpdatesAndPreservesCreatedAt(t *testing.T) {
ctx := context.Background()
s, _ := newPGUserStore(t)
id := uuid.NewString()
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "old@example.com", Name: "Old"}))
first, err := s.Get(ctx, id)
require.NoError(t, err)
origCreated := first.CreatedAt
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "new@example.com", Name: "New", Roles: []string{"r"}}))
second, err := s.Get(ctx, id)
require.NoError(t, err)
assert.Equal(t, "new@example.com", second.Email, "email 應更新")
assert.Equal(t, "New", second.Name, "name 應更新")
assert.Equal(t, []string{"r"}, second.Roles, "roles 應更新")
assert.Equal(t, origCreated, second.CreatedAt, "created_at 應保留ON CONFLICT 不動 created_at")
assert.True(t, second.UpdatedAt.After(origCreated) || second.UpdatedAt.Equal(origCreated),
"updated_at 應 >= created_at")
}
// TestPGUser_Upsert_EmailUniqueCaseInsensitive 驗證 uq_users_email_lower
// 兩個不同 sub 共用同 email差大小寫→ 第二筆撞 lower(email) unique23505
//
// 這是 D1-B 下的已知資料異常邊界(雛形原樣回錯、不靜默吞)。
func TestPGUser_Upsert_EmailUniqueCaseInsensitive(t *testing.T) {
ctx := context.Background()
s, _ := newPGUserStore(t)
require.NoError(t, s.Upsert(ctx, &User{ID: uuid.NewString(), Email: "dup@example.com"}))
err := s.Upsert(ctx, &User{ID: uuid.NewString(), Email: "DUP@example.com"})
require.Error(t, err, "不同 sub 共用同 email差大小寫應撞 uq_users_email_lower")
var pgErr *pgconn.PgError
if assert.ErrorAs(t, err, &pgErr) {
assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation")
}
}
func TestPGUser_Get_NotFound(t *testing.T) {
ctx := context.Background()
s, _ := newPGUserStore(t)
_, err := s.Get(ctx, uuid.NewString())
assert.ErrorIs(t, err, ErrNotFound)
}
// TestPGUser_Upsert_NilRolesEmptyArray 驗證 roles nil → 寫空陣列 round-trip 回空/ nil。
func TestPGUser_Upsert_NilRolesEmptyArray(t *testing.T) {
ctx := context.Background()
s, _ := newPGUserStore(t)
id := uuid.NewString()
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "noroles@example.com"}))
got, err := s.Get(ctx, id)
require.NoError(t, err)
assert.Empty(t, got.Roles, "roles 預設應為空NOT NULL DEFAULT '{}'")
}
// TestPGUser_Upsert_NameNullable 驗證 name 空字串 → 寫 NULL → 掃回空字串。
func TestPGUser_Upsert_NameNullable(t *testing.T) {
ctx := context.Background()
s, tdb := newPGUserStore(t)
id := uuid.NewString()
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "noname@example.com"}))
// 直接查 DB 確認 name 欄位是 NULL非空字串
var nameIsNull bool
require.NoError(t, tdb.Pool.QueryRow(ctx,
`SELECT name IS NULL FROM users WHERE id = $1`, id).Scan(&nameIsNull))
assert.True(t, nameIsNull, "空 name 應寫成 NULL")
got, err := s.Get(ctx, id)
require.NoError(t, err)
assert.Equal(t, "", got.Name, "NULL name 應掃回空字串")
}

View File

@ -0,0 +1,135 @@
// Package user 定義 User domain model 與 Store 介面。
//
// 背景DB-on FK 收尾,問題 #1
//
// OIDC callback 驗 id_token 成功後只寫 cookie session從不寫 users 表。DB-on有 FK
// 真人登入後任何「帶 owner_user_id FK」的寫入上傳 model → models.owner_user_id、配對 →
// devices.owner_user_id、發 pairing token → pairing_tokens.user_id都會 FK violation。
// in-memory 模式因為不檢查 FK 而藏住此問題。
//
// 修法(使用者拍板 D1-BMember Center 的 OIDC sub 確認是 UUID/GUID 格式,故 sub 可直接
// 當 users.id 主鍵 — 不需另開 oidc_sub 欄位、不需新 migration。callback 驗完 id_token 後
// 呼叫 Store.Upsert 把 user 落 DBDB-on 時in-memory 模式也呼叫對齊行為。
//
// 對齊 migrations/0001_create_users_models.up.sql 的 users 表 schema
// - id UUID PKD1-B 下即 OIDC sub
// - email TEXT NOT NULLuq_users_email_lowerlower(email) 唯一)
// - name TEXTnullable
// - roles TEXT[] NOT NULL DEFAULT '{}'
// - created_at / updated_at / deleted_at
//
// 雛形範圍刻意只含 OIDC callback provision 需要的最小欄位id / email / name / roles
// password_hash / org_id 等欄位由 DB DEFAULT 處理,本 store 不觸碰。
package user
import (
"context"
"errors"
"sync"
"time"
)
// ErrNotFound 表示指定 ID 的 User 不存在(或已軟刪除)。
var ErrNotFound = errors.New("user: not found")
// User 對應 migrations/0001 的 users 表(取 OIDC provision 所需欄位)。
//
// D1-BID 即 OIDC subMember Center sub 為 UUID 格式,可直接當 PK
type User struct {
ID string `json:"id"` // = OIDC subUUID
Email string `json:"email"` // NOT NULLupsert 必給
Name string `json:"name,omitempty"` // nullable
Roles []string `json:"roles,omitempty"` // TEXT[] NOT NULL DEFAULT '{}'
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
// Store 是 User 持久層介面。
//
// 兩個實作InMemoryStorelocal-dev fallback / 單元測試)+ PostgresStoreDB-on
// main.go 依 dbPool 是否非 nil 擇一注入OIDC callback 一行不需改地切換。
type Store interface {
// Upsert 確保此 user 存在insert 或更新 email/name/roles
//
// 語意:
// - 以 ID= OIDC sub為主鍵衝突依據ON CONFLICT (id))。
// - 既存 → 更新 email / name / roles + updated_at保留 created_at。
// - 不存在 → 新建created_at = now()。
// - in 的 Email 不可為空users.email NOT NULLcaller 須確保 OIDC email claim 存在。
Upsert(ctx context.Context, in *User) error
// Get 取得單一 user不存在或已軟刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*User, error)
}
// ==========================================================================
// InMemoryStore
// ==========================================================================
// InMemoryStore 是 local-dev fallback / 單元測試用的記憶體實作。
//
// 對齊 PostgresStore 的 Upsert 語意:既存保留 CreatedAt、更新 email/name/roles + UpdatedAt。
type InMemoryStore struct {
mu sync.RWMutex
users map[string]*User // key = idOIDC sub
}
// NewInMemoryStore 建立一個空的記憶體 Store。
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{users: make(map[string]*User)}
}
// Upsert 新增或更新 userby ID保留既有 CreatedAt。
func (s *InMemoryStore) Upsert(ctx context.Context, in *User) error {
if in == nil || in.ID == "" {
return errors.New("user: Upsert requires non-nil user with ID")
}
if in.Email == "" {
return errors.New("user: Upsert requires non-empty email")
}
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
cp := *in
cp.Roles = cloneRoles(in.Roles)
if existing, ok := s.users[in.ID]; ok && existing.DeletedAt == nil {
cp.CreatedAt = existing.CreatedAt // 保留原 CreatedAt
} else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now
}
cp.UpdatedAt = now
cp.DeletedAt = nil
s.users[in.ID] = &cp
return nil
}
// Get 取得單一 user。
func (s *InMemoryStore) Get(ctx context.Context, id string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.users[id]
if !ok || u.DeletedAt != nil {
return nil, ErrNotFound
}
cp := *u
cp.Roles = cloneRoles(u.Roles)
return &cp, nil
}
// cloneRoles 複製 roles slice避免外部後續修改影響 storein-memory copy 語意)。
func cloneRoles(in []string) []string {
if in == nil {
return nil
}
out := make([]string, len(in))
copy(out, in)
return out
}
// 編譯時檢查:確保 InMemoryStore 實作 Store。
var _ Store = (*InMemoryStore)(nil)

View File

@ -92,7 +92,16 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
sess.Email = "real@example.com"
sess.OIDCCodeVerifier = "cv-secret"
sess.AccessToken = "at-secret"
sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)}
// 含非 ASCII中文 + emoji + 特殊符號)的 Extra驗證 JSON 在真 RedisRESP binary
// 傳輸)下 UTF-8 round-trip 不被破壞。miniredis 不一定能暴露 binary 層問題,故在真 Redis 驗。
const cjkVal = "返回首頁/設定 ⚙️ — 名稱「測試」"
const cjkKey = "中文鍵"
sess.Extra = map[string]any{
"return_to": "/x",
"n": float64(7),
"zh": cjkVal,
cjkKey: "值🚀",
}
if err := store.Update(ctx, sess); err != nil {
t.Fatalf("Update: %v", err)
}
@ -108,6 +117,13 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) {
t.Fatalf("Extra round-trip mismatch: %+v", got.Extra)
}
// 非 ASCII round-trip值與「中文鍵」皆須完整保留含多位元組 UTF-8 與 emoji
if got.Extra["zh"] != cjkVal {
t.Fatalf("non-ASCII Extra value round-trip mismatch: got %q want %q", got.Extra["zh"], cjkVal)
}
if got.Extra[cjkKey] != "值🚀" {
t.Fatalf("non-ASCII Extra key round-trip mismatch: got %q (key=%q)", got.Extra[cjkKey], cjkKey)
}
// key 帶 prefix用底層 client 直接驗)。
if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 {

View File

@ -37,6 +37,14 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
/** input_shapeNCHW如 [1,3,224,224])→ 易讀字串 `1 × 3 × 224 × 224`。 */
function formatInputShape(shape: number[]): string {
return shape.join(" × ");
}
/** classes 多時只展示前幾個 Badge其餘以 `+N` 表示,避免塞爆版面。 */
const CLASSES_PREVIEW_LIMIT = 8;
interface ModelDetailClientProps {
id: string;
}
@ -156,6 +164,23 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
label={t("models.createdAt")}
value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"}
/>
{/* B4 轉檔 metadata有值才顯示沒值時 backend omitemptynormalize 後為 undefined。 */}
{selectedModel.framework && (
<InfoRow
label={t("models.detail.framework")}
value={<span className="font-mono text-xs">{selectedModel.framework}</span>}
/>
)}
{selectedModel.inputShape && selectedModel.inputShape.length > 0 && (
<InfoRow
label={t("models.detail.inputShape")}
value={
<span className="font-mono text-xs">
{formatInputShape(selectedModel.inputShape)}
</span>
}
/>
)}
{selectedModel.version && (
<InfoRow label={t("models.detail.version")} value={selectedModel.version} />
)}
@ -166,6 +191,30 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
/>
)}
</div>
{/* classes 可能很長,獨立成 full-width 區塊,用 Badge 列出(超量只顯示前 N 個 + 餘量)。 */}
{selectedModel.classes && selectedModel.classes.length > 0 && (
<div className="space-y-2 border-t pt-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
<span className="text-muted-foreground text-xs">
{selectedModel.classes.length} {t("models.detail.classesCountSuffix")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{selectedModel.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
{c}
</Badge>
))}
{selectedModel.classes.length > CLASSES_PREVIEW_LIMIT && (
<Badge variant="outline" className="font-normal">
+{selectedModel.classes.length - CLASSES_PREVIEW_LIMIT}
</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>

View File

@ -195,6 +195,10 @@ export const en: Dictionary = {
"models.detail.checksum": "Checksum",
"models.detail.supportedChips": "Supported chips",
"models.detail.deployToDevice": "Deploy to device",
"models.detail.inputShape": "Input shape",
"models.detail.framework": "Framework",
"models.detail.classes": "Classes",
"models.detail.classesCountSuffix": "classes",
// ── Model Upload Dialog ──
"models.upload.button": "Upload model",

View File

@ -196,6 +196,10 @@ export const zhHant: Dictionary = {
"models.detail.checksum": "校驗碼",
"models.detail.supportedChips": "支援晶片",
"models.detail.deployToDevice": "部署至裝置",
"models.detail.inputShape": "輸入形狀",
"models.detail.framework": "來源框架",
"models.detail.classes": "類別",
"models.detail.classesCountSuffix": "個類別",
// ── Model Upload Dialogflow-model-upload ──
"models.upload.button": "上傳模型",

View File

@ -18,6 +18,9 @@ import { useAuthStore } from "./auth-store";
* - fetchMe hydrate
* - logout 200 user
* - logout backend userbest-effort
* - logout idp_logout MC iframe
* - logout idp_logout iframe
* - logout iframe onload fallback timer resolve
* - localStorageOF6
*/
@ -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 — IdPMember Center連動登出 */
/* ------------------------------------------------------------------------ */
it("logoutresponse 帶 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 iframeonload 不觸發)→ 走 fallback timer。
// 用 fake timers 控制logout 的 promise 會等到 timer 觸發才 resolve。
vi.useFakeTimers();
try {
const logoutPromise = useAuthStore.getState().logout();
// 等 backend POST + set state 完成microtask flushiframe 此時應已建立
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 timer2000ms→ logout 應 resolve、iframe 被移除
await vi.advanceTimersByTimeAsync(2000);
await logoutPromise;
expect(document.querySelector("iframe")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("logoutiframe 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("logoutresponse 無 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("logoutidp_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();
});
/* ------------------------------------------------------------------------ */

View File

@ -9,6 +9,8 @@
*
* - 使 backend `GET /api/auth/me`
* - hydrateapp boot/ fetchMe refresh/ logout actions
* - logout IdPMember 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` IdPMember Center**optional**
* backend logout URL visionA
* - `method` GETstage MC POST
*/
interface LogoutResponse {
success?: boolean;
idp_logout?: {
url: string;
method?: string;
};
}
/**
* IdPMember 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 {
};
}
/* -------------------------------------------------------------------------- */
/* IdPMember Center連動登出 */
/* -------------------------------------------------------------------------- */
/**
* visionA Member Center MC session
* cookie使
*
* iframe `url`GET MC session
* 使 visionAMC 使 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 cookiecross-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;
}
// Fallbackiframe 被擋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 才連動 MCawait 確保 caller 導向 /login 前 MC session 已(盡力)清完。
if (idpLogoutUrl) {
await triggerIdpLogout(idpLogoutUrl);
}
},
_setUser: (user) => {

View File

@ -58,6 +58,13 @@ export interface Model extends ModelSummary {
version?: string;
checksum?: string;
downloadUrl?: string; // presigned GET
/**
* metadataB4 backend result omitempty JSON
* optional
*/
inputShape?: number[]; // NCHW如 [1, 3, 224, 224]
classes?: string[]; // 類別清單
framework?: string; // 來源框架,如 onnx / tflite
}
/** `POST /api/models/init` 的回傳(對齊 api-spec §4 */
@ -94,6 +101,22 @@ function normalizeModelSummary(raw: unknown): ModelSummary {
};
}
/** 把後端帶來的 metadata 陣列正規化:只接受陣列、過濾掉 null/undefined空陣列回 undefined。 */
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v));
return arr.length > 0 ? arr : undefined;
}
/** input_shapenumber[],過濾非有限數值;空陣列回 undefined讓 UI「有值才顯示」。 */
function normalizeNumberArray(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value.map((v) => Number(v)).filter((n) => Number.isFinite(n));
return arr.length > 0 ? arr : undefined;
}
function normalizeModel(raw: unknown): Model {
const summary = normalizeModelSummary(raw);
const r = (raw ?? {}) as Record<string, unknown>;
@ -106,6 +129,9 @@ function normalizeModel(raw: unknown): Model {
(r.download_url as string | undefined) ??
(r.downloadUrl as string | undefined) ??
undefined,
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
classes: normalizeStringArray(r.classes),
framework: (r.framework as string | undefined) ?? undefined,
};
}