Compare commits
No commits in common. "c2f0b1549ea858871cd80a7303fddfe5118a321d" and "4d0b87048024746d3f2df66aa3e507a0760bf534" have entirely different histories.
c2f0b1549e
...
4d0b870480
4
.gitignore
vendored
4
.gitignore
vendored
@ -62,7 +62,3 @@ local-tool/visiona-local/payload/
|
|||||||
# 共享文件(PRD / 設計 / 架構 / 交付)走 docs/ 進 git
|
# 共享文件(PRD / 設計 / 架構 / 交付)走 docs/ 進 git
|
||||||
.autoflow/
|
.autoflow/
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
# sub-agent 工作 log(per-branch、不進 git)
|
|
||||||
.autoflow-logs/
|
|
||||||
**/.autoflow-logs/
|
|
||||||
|
|||||||
@ -58,30 +58,18 @@ server {
|
|||||||
# /healthz 例外:Docker healthcheck 從 container 內打 localhost/healthz
|
# /healthz 例外:Docker healthcheck 從 container 內打 localhost/healthz
|
||||||
# (Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任)
|
# (Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任)
|
||||||
# 限制 source = 127.0.0.0/8 防止外部偽造 Host 跳過白名單
|
# 限制 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 healthcheck(Host: localhost、來源 127.0.0.1)
|
|
||||||
# 的 /healthz 也被打成 444 → container 長期 unhealthy(false 444)。
|
|
||||||
# 正解:把 catch-all 444 收進 `location /`,讓 exact-match `location = /healthz`
|
|
||||||
# 依 nginx location 優先序勝出。
|
|
||||||
location = /healthz {
|
location = /healthz {
|
||||||
allow 127.0.0.0/8;
|
allow 127.0.0.0/8;
|
||||||
allow ::1/128;
|
allow ::1/128;
|
||||||
deny all;
|
deny all;
|
||||||
access_log off;
|
# 直接內部回 200,不轉到 api-server(避免 api-server 也要實作 /healthz)
|
||||||
# 直接內部回 200,不轉到 api-server(淺層:只證明 nginx 活著)
|
|
||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
add_header Content-Type "text/plain" always;
|
add_header Content-Type "text/plain" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 其他任何 host header 不符合白名單 → 444 close(不回 response 給攻擊者反饋)
|
# 其他任何 host header 不符合白名單 → 444 close(不回 response 給攻擊者反饋)
|
||||||
# 用 location / 包起來(而非 server-level return),才不會 shadow 掉上面的 /healthz。
|
|
||||||
location / {
|
|
||||||
return 444;
|
return 444;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@ -121,11 +109,7 @@ server {
|
|||||||
# 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加)
|
# 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 健康檢查(淺層)— 不打到 backend
|
# 健康檢查 — 不打到 backend,docker healthcheck 用
|
||||||
# 用途:「nginx 程序活著」的最廉價證明。docker healthcheck(走 default_server
|
|
||||||
# 那條)與「只想確認反代層在線」的外部探針用這條。
|
|
||||||
# ⚠️ 注意:這條「不」反映 DB / Redis 健康 —— load balancer 若要在 DB 掛掉時
|
|
||||||
# 把本實例踢出輪替,必須打下面的 /healthz/deep,不能打這條。
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
location = /healthz {
|
location = /healthz {
|
||||||
access_log off;
|
access_log off;
|
||||||
@ -133,38 +117,6 @@ server {
|
|||||||
add_header Content-Type text/plain;
|
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):健康探針不該 hang;backend 自身 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/* → api-server :3721
|
||||||
# 包含 /api/auth/*(OIDC callback)、/api/devices、/api/models、/api/pairing 等
|
# 包含 /api/auth/*(OIDC callback)、/api/devices、/api/models、/api/pairing 等
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
# B4 交接檔:轉檔服務端串接 `analysis_info`(input_shape metadata)
|
|
||||||
|
|
||||||
> **狀態**:Draft — 給 `kneron_model_converter` repo 的人實作
|
|
||||||
> **作者**:Architect Agent(visionA)
|
|
||||||
> **最後更新**: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 record(Redis) |
|
|
||||||
| 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 目標 JSON(GET /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_info;onnx / 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. 跑一個完整轉檔 job(onnx → 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` test:job record 有 `analysis_info` → output 有 top-level `analysis_info`;沒有 → output 為 `null`
|
|
||||||
- 既有 `getJobs.integration.test.js` 可加一個 case:job 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-1482,4 個 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 L88):visionA 不讀,往上傳無害,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 那段寫,否則會漏。
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
# 交接檔:轉檔服務 promote 401 根因 + 修法(OAuth client 認證送法)
|
|
||||||
|
|
||||||
> 對象:維護 `kneron_model_converter` 的工程師
|
|
||||||
> 來源:visionA 端(Orchestrator 實測定位)
|
|
||||||
> 狀態:根因已精確定位並實測排除其他可能,待轉檔服務側修正
|
|
||||||
> 最後更新:2026-06-22
|
|
||||||
> 語言:zh-TW
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 一句話結論
|
|
||||||
|
|
||||||
轉檔服務的 OAuth client 用 **HTTP Basic Auth** 把 client 認證送給 Member Center(MC,OpenIddict),但 **MC 拒絕 Basic Auth、只接受 `client_id` / `client_secret` 放在 POST form body**(OAuth2 的 `client_secret_post` 方式)。把認證從 Basic header 改成 form body 即可解決。憑證、scope、endpoint、真轉檔全部正常,不需要動。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 現象
|
|
||||||
|
|
||||||
visionA「轉檔 → 進模型庫」鏈路的最後一步 promote 一直失敗:
|
|
||||||
|
|
||||||
- visionA 呼叫轉檔服務 `POST /api/v1/jobs/{id}/promote`
|
|
||||||
- 回應 `500`:
|
|
||||||
```json
|
|
||||||
{ "error": { "code": "internal_error", "message": "promote 過程發生未預期錯誤" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### scheduler log 證據(依序)
|
|
||||||
|
|
||||||
```
|
|
||||||
oauth.token_endpoint_error scope:"files:upload.write" status:401 error_code:"invalid_client"
|
|
||||||
promote.faa_put_failed OAuthClientError 401
|
|
||||||
→ 對外回 500
|
|
||||||
```
|
|
||||||
|
|
||||||
也就是說:promote 階段去 MC 換 service token 時,MC 回 `401 invalid_client`,導致後續 FAA PUT 拿不到 token,最終轉檔服務對 visionA 回 500。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 精確根因
|
|
||||||
|
|
||||||
`apps/task-scheduler/src/auth/oauthClient.js` 取 token 時,把 client 認證放在 **HTTP Basic Auth header**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Basic base64(client_id:client_secret)
|
|
||||||
```
|
|
||||||
|
|
||||||
而 body 只帶 `grant_type` / `scope` / `audience`。
|
|
||||||
|
|
||||||
MC(OpenIddict)**不接受 Basic Auth 形式的 client 認證**,只接受 `client_id` / `client_secret` 放在 `application/x-www-form-urlencoded` 的 POST body(即 OAuth2 spec 的 `client_secret_post` token endpoint auth method)。
|
|
||||||
|
|
||||||
### 實測對照表(同一組 client_id + 同一個 secret,打同一個 MC endpoint)
|
|
||||||
|
|
||||||
| 認證送法 | 請求內容 | MC 回應 |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| **form body**(`client_secret_post`) | body 含 `client_id` / `client_secret` / `grant_type` / `scope`,**無** Authorization header | ✅ **成功拿到 token**。JWT 解出 `scope: files:upload.write`、`aud: file_access_api` |
|
|
||||||
| **HTTP Basic Auth**(`client_secret_basic`) | `Authorization: Basic base64(client_id:client_secret)`,body 只有 grant_type/scope | ❌ `401` `invalid_client`「The specified client credentials are invalid.」 |
|
|
||||||
|
|
||||||
- client_id:`4242ba63099d4f318dd3f143d27ef4c5`
|
|
||||||
- MC token endpoint:`https://stage-9527.innovedus.com:7850/oauth/token`
|
|
||||||
- 兩種方式用的是**完全相同**的 client_id + secret,差別只在「認證放哪」。
|
|
||||||
|
|
||||||
> 結論:`invalid_client` 不是「憑證錯」,是「MC 不認 Basic Auth 這種送法」。OpenIddict 預設行為即如此(client 須在 client registration 設定允許的 auth method;此 client 走 form body)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 已排除的其他可能(請不要往這些方向查,會浪費時間)
|
|
||||||
|
|
||||||
| 懷疑點 | 結論 | 證據 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 憑證錯(client_id / secret 不對) | ❌ 不是 | 轉檔服務 container 內的 `KNERON_CONVERTER_CLIENT_SECRET` 與 warrenchen 給的 secret **sha256 完全一致**;且 form body 方式用同一組憑證能成功換到 token |
|
|
||||||
| scope 沒授權 | ❌ 不是 | MC 已授權此 client `files:upload.write`;form body 方式換到的 token JWT 內 `scope` 即含 `files:upload.write` |
|
|
||||||
| token endpoint URL 設錯 | ❌ 不是 | 轉檔服務設的 `MEMBER_CENTER_TOKEN_URL` 與實測成功的 URL 相同(`.../oauth/token`) |
|
|
||||||
| FAA audience 錯 | ❌ 不是 | form body 換到的 token `aud: file_access_api`,與預期一致 |
|
|
||||||
| 真轉檔(KTC)有問題 | ❌ 不是 | 真 KTC 轉檔已成功,產出 nef 真檔(約 800KB);promote 卡的純粹是 OAuth 換 token 那一步 |
|
|
||||||
|
|
||||||
**唯一變因就是 client 認證的送法(Basic header vs form body)。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 要改的位置與改法
|
|
||||||
|
|
||||||
### 檔案
|
|
||||||
`apps/task-scheduler/src/auth/oauthClient.js`
|
|
||||||
|
|
||||||
### 當前 code 位置(已確認,行號為現況)
|
|
||||||
|
|
||||||
1. **`buildBasicAuthHeader()`**(lines 91–95)— 產生 `Basic base64(id:secret)`:
|
|
||||||
```js
|
|
||||||
function buildBasicAuthHeader(clientId, clientSecret) {
|
|
||||||
const raw = `${clientId}:${clientSecret}`;
|
|
||||||
return `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **`_fetchToken()` 內組 body / headers 的地方**(lines 299–309):
|
|
||||||
```js
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
grant_type: 'client_credentials',
|
|
||||||
scope,
|
|
||||||
audience: config.faaAudience,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: buildBasicAuthHeader(config.clientId, config.clientSecret), // ← 問題在這
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **檔頭 design 註解**(lines 19–24)目前寫死「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 —— 改完一併更新,避免下一個人又改回去。
|
|
||||||
|
|
||||||
### 改法(最小修正:Basic header → form body)
|
|
||||||
|
|
||||||
把 `client_id` / `client_secret` 從 Basic header 移進 body,移除 `Authorization` header:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
grant_type: 'client_credentials',
|
|
||||||
client_id: config.clientId, // ← 新增
|
|
||||||
client_secret: config.clientSecret, // ← 新增
|
|
||||||
scope,
|
|
||||||
audience: config.faaAudience,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Accept: 'application/json',
|
|
||||||
// 不再送 Authorization Basic header
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- `buildBasicAuthHeader()` 改完後若無其他使用處可移除(連同 `_internals` 的 export 與相關測試)。
|
|
||||||
- 這是 OAuth2 spec(RFC 6749 §2.3.1)允許的兩種 client 認證方式之一(`client_secret_post`),MC 接受這種。
|
|
||||||
|
|
||||||
### 安全注意(務必保留)
|
|
||||||
|
|
||||||
- 原本「**絕不**把 `client_secret` / token / Authorization 內容寫入 log」的約束**必須維持**。`client_secret` 移進 body 後,一樣**不能**出現在任何 log(注意:`URLSearchParams` 字串、`body` 變數、錯誤訊息都不可被 log 出來)。
|
|
||||||
- 檔內 `logEvent()` 目前不 log body,維持即可;新增/修改時別不小心把 `body` 帶進 log fields。
|
|
||||||
- `tryParseOauthErrorBody` / error log 維持只揭露 `status` + 標準 `error_code`,不要為了 debug 把 request body dump 出來。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 改完怎麼驗
|
|
||||||
|
|
||||||
### 驗法 A:直接 curl MC(最快,先確認 MC 端接受 form body)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST 'https://stage-9527.innovedus.com:7850/oauth/token' \
|
|
||||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
|
||||||
-H 'Accept: application/json' \
|
|
||||||
--data-urlencode 'grant_type=client_credentials' \
|
|
||||||
--data-urlencode 'client_id=4242ba63099d4f318dd3f143d27ef4c5' \
|
|
||||||
--data-urlencode 'client_secret=<從環境變數帶入,勿貼進 shell history>' \
|
|
||||||
--data-urlencode 'scope=files:upload.write' \
|
|
||||||
--data-urlencode 'audience=<faaAudience,對應 config.fileAccessAgent.audience>'
|
|
||||||
```
|
|
||||||
預期:回 `200` + `{ access_token, token_type, expires_in, ... }`。把 `access_token` 丟 jwt.io 解,應看到 `scope: files:upload.write`、`aud: file_access_api`。
|
|
||||||
|
|
||||||
> 安全提醒:`client_secret` 不要直接貼在指令裡(會進 shell history)。用 `--data-urlencode "client_secret=$KNERON_CONVERTER_CLIENT_SECRET"` 從環境變數帶。
|
|
||||||
|
|
||||||
### 驗法 B:重跑 promote(端到端)
|
|
||||||
|
|
||||||
改完部署後,由 visionA 重新觸發一次轉檔 → promote:
|
|
||||||
|
|
||||||
- scheduler log 應從 `oauth.token_endpoint_error status:401` 變成 `oauth.token_obtained scope:"files:upload.write"`。
|
|
||||||
- promote 對 visionA 回 `200`(不再 500)。
|
|
||||||
- nef 成功推進 FAA / 模型庫。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 小提醒(可選,非必要)
|
|
||||||
|
|
||||||
- 若想更穩健,可讓 `oauthClient` 用 config 支援兩種 auth method(`client_secret_basic` / `client_secret_post`)切換,預設走 `post`。但**目前最簡單、足夠解決問題的做法就是直接改成 form body**,不需要為此加複雜度。
|
|
||||||
- 改完記得更新 `oauthClient.js` 檔頭 design 註解(lines 19–24)說明改用 `client_secret_post`,否則註解與實作不一致,未來容易被誤改回 Basic。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附錄:與我描述略有出入的實際 code 結構(供對方校正)
|
|
||||||
|
|
||||||
- `buildBasicAuthHeader()` 實際在 **lines 91–95**(描述為 ~91–94)。
|
|
||||||
- 認證 header 實際送出處在 **lines 305–309(headers 物件)/ 316–321(fetch)**,`Authorization` 設定在 **line 308**(描述為 ~300–308,大致吻合)。
|
|
||||||
- **額外發現**:body 除了 `grant_type` / `scope`,還帶了 `audience: config.faaAudience`(line 302)。改成 form body 時,是在這個**既有 body** 上新增 `client_id` / `client_secret`,不是憑空新建 body。
|
|
||||||
- 檔頭 lines 19–24 的 design 註解明文寫「使用 HTTP Basic auth header」並引 RFC 6749 §2.3.1 當理由 —— 這是當初的設計決策,改 code 時要連這段註解一起改掉。
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
# 交接文件:MC id_token 缺 email claim(導致 visionA 建 user 500)
|
|
||||||
|
|
||||||
## 作者:Architect Agent(visionA 端)
|
|
||||||
## 對象:Member Center 團隊
|
|
||||||
## 狀態:待 MC 修復
|
|
||||||
## 最後更新:2026-06-26
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 一句話總結
|
|
||||||
|
|
||||||
**MC(Member Center)發出的 OIDC id_token 永遠沒有 `email` claim,根因是 MC 用 ASP.NET Identity 的「預設」`UserClaimsPrincipalFactory`(它只放 NameIdentifier / Name / Role,不放 email),導致 visionA 拿到空 email 後建 user 失敗回 500。**
|
|
||||||
|
|
||||||
與 user 的 `EmailConfirmed` 狀態無關 —— 預設 factory 根本不把 email 放進 principal,不管 confirmed 與否。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 現象(visionA 端觀察到的)
|
|
||||||
|
|
||||||
1. 使用者透過 MC 登入(OIDC Authorization Code Flow)。
|
|
||||||
2. visionA callback 拿到 id_token,解析後 **`email` claim 為空 / 不存在**。
|
|
||||||
3. visionA 端 provision user 失敗:
|
|
||||||
|
|
||||||
```
|
|
||||||
oidc.callback: provision user failed error:"user: Upsert requires non-empty email"
|
|
||||||
```
|
|
||||||
|
|
||||||
4. visionA 回 HTTP 500:`failed to provision user`。
|
|
||||||
|
|
||||||
visionA 端 email 為**必填**(fail-closed):沒有 email 就無法建立 / 更新 user,因此 id_token 缺 email 直接導致登入失敗。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 精確根因(已深入 MC codebase 坐實)
|
|
||||||
|
|
||||||
### 3.1 核心:依賴 ASP.NET Identity 預設的 ClaimsPrincipalFactory
|
|
||||||
|
|
||||||
MC 在發 token 前,用 `SignInManager.CreateUserPrincipalAsync(user)` 建立 `ClaimsPrincipal`,但**全 codebase 沒有自訂 `IUserClaimsPrincipalFactory`**(grep `IUserClaimsPrincipalFactory` / `UserClaimsPrincipalFactory` 在 `src/` 下 0 命中)。
|
|
||||||
|
|
||||||
ASP.NET Identity(8.0.11)的預設 `UserClaimsPrincipalFactory<TUser>` 只會放入:
|
|
||||||
- `ClaimTypes.NameIdentifier`(= user Id,對應 OIDC `sub`)
|
|
||||||
- `ClaimTypes.Name`(= UserName)
|
|
||||||
- 使用者的 Role claims(若有)
|
|
||||||
|
|
||||||
**它不會放入 `email` claim。** 所以無論下游 scope / destination 怎麼設定,principal 裡根本沒有 email claim 可發。
|
|
||||||
|
|
||||||
### 3.2 證據(檔案 + 行號,當前 MC code 位置)
|
|
||||||
|
|
||||||
| 檔案 | 行號 | 內容 | 問題 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| `src/MemberCenter.Api/Controllers/OAuthController.cs` | 40 | `var principal = await _signInManager.CreateUserPrincipalAsync(user);` | 之後(line 42-45)只做 destination 路由,**沒有手動加 email claim** |
|
|
||||||
| `src/MemberCenter.Api/Controllers/TokenController.cs` | 60 | `var principal = await _signInManager.CreateUserPrincipalAsync(user);`(password grant 分支) | 同樣,line 65-68 只做 destination 路由,**沒有手動加 email claim** |
|
|
||||||
| `src/MemberCenter.Api/Extensions/ClaimsExtensions.cs` | 26-27 | `Name or Email => { AccessToken, IdentityToken }` | 路由邏輯**已寫好**把 email 送進 IdentityToken,**但前提是 principal 裡已有 email claim —— 實際沒有**(這是最迷惑的點:destination 規則寫對了,但 source claim 從沒被加進 principal) |
|
|
||||||
| `src/MemberCenter.Api/Program.cs` | 30-41 | `AddIdentity<...>().AddEntityFrameworkStores<...>().AddDefaultTokenProviders()` | 無 `AddClaimsPrincipalFactory<...>`,**沿用預設 factory**;也沒有設定自訂 EmailClaimType |
|
|
||||||
|
|
||||||
### 3.3 關鍵釐清:destination 規則 ≠ claim 來源
|
|
||||||
|
|
||||||
`ClaimsExtensions.GetDestinations()`(line 22-30)的作用是「**如果**有 email claim,把它路由到 IdentityToken」。但它無法「製造」email claim。
|
|
||||||
|
|
||||||
可以把這想成兩段:
|
|
||||||
1. **產生 claim**(誰負責把 email 放進 principal)→ **目前沒人做**(缺的就是這段)
|
|
||||||
2. **路由 claim**(決定 claim 進 access_token 還是 id_token)→ 已正確實作(ClaimsExtensions)
|
|
||||||
|
|
||||||
第 1 段缺失,第 2 段再正確也沒用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 已排除的可能(附 DB / 設定查證,免 MC 團隊走冤枉路)
|
|
||||||
|
|
||||||
| 懷疑點 | 是否為根因 | 查證結果 |
|
|
||||||
|--------|-----------|---------|
|
|
||||||
| visionA 沒要 email scope | ❌ 不是 | visionA 的 `DefaultScopes` 含 `email`,已驗證 |
|
|
||||||
| client 沒被授權 email scope | ❌ 不是 | MC 的 `OpenIddictApplications` 中 visionA client `b8093fea1a504a5d8f0e04bee9f78f2e` 的 Permissions 含 `scp:email`,已查 DB 確認 |
|
|
||||||
| 使用者沒有 email | ❌ 不是 | user `b5332e51` 的 `Email = jim800121.chen@gmail.com`,已查 DB 確認。(且 `Program.cs` line 33 `RequireUniqueEmail = true`,MC 所有 user 必有 email) |
|
|
||||||
| `EmailConfirmed = false` 導致不發 email | ❌ 不是 | 該 user `EmailConfirmed = false`,但**根因是預設 factory 根本不放 email claim,與 confirmed 與否無關**。即使 confirmed = true,預設 factory 仍不放 email |
|
|
||||||
|
|
||||||
**結論**:scope、client 授權、user email、EmailConfirmed 全部正常 / 不相關。唯一缺口是 §3 的 claims factory。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 修法(給 MC 團隊選)
|
|
||||||
|
|
||||||
### 方案 (a)【推薦】自訂 `UserClaimsPrincipalFactory`
|
|
||||||
|
|
||||||
集中、乾淨,所有發 token 路徑(OAuthController / TokenController)一次涵蓋。
|
|
||||||
|
|
||||||
1. 在 `src/MemberCenter.Infrastructure/Identity/` 新建 `CustomUserClaimsPrincipalFactory`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using OpenIddict.Abstractions;
|
|
||||||
|
|
||||||
namespace MemberCenter.Infrastructure.Identity;
|
|
||||||
|
|
||||||
public class CustomUserClaimsPrincipalFactory
|
|
||||||
: UserClaimsPrincipalFactory<ApplicationUser, ApplicationRole>
|
|
||||||
{
|
|
||||||
public CustomUserClaimsPrincipalFactory(
|
|
||||||
UserManager<ApplicationUser> userManager,
|
|
||||||
RoleManager<ApplicationRole> roleManager,
|
|
||||||
IOptions<IdentityOptions> options)
|
|
||||||
: base(userManager, roleManager, options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
|
|
||||||
{
|
|
||||||
var identity = await base.GenerateClaimsAsync(user);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
|
||||||
{
|
|
||||||
// OpenIddict 用的 claim type 為 "email"(OpenIddictConstants.Claims.Email)
|
|
||||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Email, user.Email));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 建議連 email_verified 一起放(見 §5.3)
|
|
||||||
identity.AddClaim(new Claim(
|
|
||||||
OpenIddictConstants.Claims.EmailVerified,
|
|
||||||
user.EmailConfirmed ? "true" : "false",
|
|
||||||
ClaimValueTypes.Boolean));
|
|
||||||
|
|
||||||
return identity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 在 `Program.cs`(line 30-41 的 Identity 設定鏈)註冊,取代預設 factory:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services
|
|
||||||
.AddIdentity<ApplicationUser, ApplicationRole>(options => { /* 既有設定不變 */ })
|
|
||||||
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
|
||||||
.AddDefaultTokenProviders()
|
|
||||||
.AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory>(); // ← 新增這行
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **destination 路由不用改** —— `ClaimsExtensions.GetDestinations()`(line 26-27)已經會把 `email` 與 `email_verified`(若要進 id_token 需確認 case,見下)送對地方。
|
|
||||||
- ⚠️ 注意:目前 `GetDestinations` 只對 `Name` / `Email` 回 IdentityToken。`email_verified` 不在其中,會只進 access_token。若希望 `email_verified` 也進 id_token,需在 `ClaimsExtensions.cs` line 26 的 switch 加上 `OpenIddictConstants.Claims.EmailVerified`。
|
|
||||||
|
|
||||||
### 方案 (b)【快速】在兩個 Controller 手動加 email claim
|
|
||||||
|
|
||||||
較分散(兩處都要改),但改動最小。
|
|
||||||
|
|
||||||
- `OAuthController.cs` line 40 之後、line 42 的 foreach 之前插入:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
if (!string.IsNullOrWhiteSpace(user.Email) &&
|
|
||||||
!principal.HasClaim(c => c.Type == OpenIddictConstants.Claims.Email))
|
|
||||||
{
|
|
||||||
((ClaimsIdentity)principal.Identity!).AddClaim(
|
|
||||||
new Claim(OpenIddictConstants.Claims.Email, user.Email));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `TokenController.cs` line 60 之後、line 65 的 foreach 之前插入相同邏輯(password grant 分支)。
|
|
||||||
- ⚠️ 缺點:refresh token grant(line 73-83)沿用既有 principal,若首次發 token 沒加 email、refresh 出來的也不會有。方案 (a) 因為在 factory 層處理,refresh 重新驗證時也會走到(取決於 OpenIddict refresh 流程),較不易漏;建議優先 (a)。
|
|
||||||
|
|
||||||
### 5.3 建議一併加 `email_verified` claim
|
|
||||||
|
|
||||||
`email_verified` 是 OIDC 標準 claim(boolean),值 = `user.EmailConfirmed`。下游(visionA)可據此決定要不要信任 email 或要求驗證。已包含在 §5(a) 範例中。
|
|
||||||
|
|
||||||
### 5.4 Trade-off:是否要 `EmailConfirmed = true` 才發 email claim?(需 MC + visionA 對齊)
|
|
||||||
|
|
||||||
- **MC 若選擇「只在 confirmed 才發 email」**:未驗證的 user 仍會讓 visionA 拿到空 email → visionA 端仍 500(因 visionA email 必填、fail-closed)。
|
|
||||||
- **建議做法**:MC **無條件發 `email` claim**(不管 confirmed),另用 `email_verified` 標記驗證狀態。是否擋未驗證 user 由 visionA 端自行決定(visionA 可選擇接受未驗證 email 先建 user,或讀到 `email_verified=false` 時擋下並引導驗證)。
|
|
||||||
- 這個決策需 MC 與 visionA 雙方確認後落地,避免「MC 改了但 visionA 仍 500」。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 改完怎麼驗
|
|
||||||
|
|
||||||
1. **MC 自驗**(不需 visionA):用 visionA client 走一次 Authorization Code Flow(或直接 password grant 取 token),拿到 id_token 後到 <https://jwt.io> 或自行 decode,確認 payload 含:
|
|
||||||
- `email`: `<user 的 email>`
|
|
||||||
- `email_verified`: `true` / `false`(若採 §5.3)
|
|
||||||
- `sub`: `<user id>`
|
|
||||||
2. **端到端驗**:visionA 重新登入 MC,確認:
|
|
||||||
- callback 拿到的 id_token 含非空 `email` claim
|
|
||||||
- 不再出現 `oidc.callback: provision user failed error:"user: Upsert requires non-empty email"`
|
|
||||||
- 登入成功(不再 500)、user 正確建立 / 更新
|
|
||||||
3. **回歸**:確認既有 access_token 的 claim / scope 行為沒被破壞(password grant、client_credentials grant 不涉及 user email,理論上不受影響,但建議一併冒煙測試)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 附錄:claim type 命名確認事項(請 MC 改時驗證)
|
|
||||||
|
|
||||||
- 範例使用 `OpenIddictConstants.Claims.Email`(值為字串 `"email"`)與 `OpenIddictConstants.Claims.EmailVerified`(`"email_verified"`)。
|
|
||||||
- 請確認與 `ClaimsExtensions.GetDestinations()`(line 26)switch 比對的 `OpenIddictConstants.Claims.Email` 為**同一常數**,確保 destination 路由能命中(這點目前 code 已一致,沿用同一常數即可)。
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
# 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(深層 readiness,ping 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/:3801(tunnel)
|
|
||||||
│
|
|
||||||
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`(Up,label 顯示屬於同 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 HEAD(remote 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-5),image 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` 回 503,PG 仍 `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 顯示 initialized(converter base url 正確) |
|
|
||||||
| FAA download(Phase 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-20,commit 於 `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 block(server_name stage-9527.innovedus.com)
|
|
||||||
location = /healthz/deep {
|
|
||||||
access_log off;
|
|
||||||
proxy_pass http://visiona_api/healthz; # api-server :3721,ping 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 10–15s、timeout 5s、unhealthy threshold 連續 3 次(對齊 backend health.go 的 2s ping 逾時 + nginx 2s proxy 逾時)
|
|
||||||
- 淺層 `/healthz` 保留給 docker healthcheck / liveness(DB 抖動時不會誤把實例標死)
|
|
||||||
|
|
||||||
**實測(2026-06-20,log:`.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 → healthy(streak=0;修復前 unhealthy streak=96)
|
|
||||||
停 visiona-redis(503 路徑):
|
|
||||||
公開 /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-memory;PG/Redis 資料保留但不被讀。)
|
|
||||||
|
|
||||||
## 12. 煙測 evidence log 清單
|
|
||||||
|
|
||||||
| log | 內容 |
|
|
||||||
|-----|------|
|
|
||||||
| `.autoflow/07-delivery/logs/block6-deploy-202606201848.log` | build + compose up(exit 0) |
|
|
||||||
| `.autoflow/07-delivery/logs/block6-container-startup-202606201848.log` | 啟動 log(DB/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 冪等 |
|
|
||||||
@ -76,24 +76,6 @@ VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback
|
|||||||
# prod: https://app.visiona.cloud
|
# prod: https://app.visiona.cloud
|
||||||
VISIONA_FRONTEND_URL=http://localhost:3000
|
VISIONA_FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
# MC 連動登出入口(讓使用者登出 visionA 後能換帳號)
|
|
||||||
# 背景:Member Center 不支援標準 OIDC RP-initiated logout(discovery 的
|
|
||||||
# end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client 未註冊
|
|
||||||
# post_logout_redirect_uri),後端無法乾淨地 302 導向 MC end_session。
|
|
||||||
# 唯一瀏覽器可觸發的 MC 登出是 MemberCenter.Web(:7880)的 /account/logout(GET 即 302)。
|
|
||||||
# 設定本變數後,POST /api/auth/logout 的 response 會多回 idp_logout(url + method=GET),
|
|
||||||
# 由前端 navigate(window.location)觸發 MC 登出。
|
|
||||||
# **host 必須是 MC Web :7880,不是 MC Api :7850**(:7850 沒 logout 頁、會 404)。
|
|
||||||
# **留空=logout 只清 visionA 本地 session、不連動 MC(向下相容)。**
|
|
||||||
# stage 範例:https://stage-9527.innovedus.com:7880/account/logout
|
|
||||||
VISIONA_OIDC_LOGOUT_URL=
|
|
||||||
|
|
||||||
# 強制每次登入都重新認證(OIDC prompt=login)
|
|
||||||
# - true → authorize request 帶 prompt=login,IdP 忽略既有 SSO session、要求重新輸入帳密。
|
|
||||||
# - false → 沿用 IdP 既有 session(標準 SSO 體驗),為預設值。
|
|
||||||
# stage 想「每次點登入都重新輸入帳密」時設 true;prod 視 UX 決定(通常 false)。
|
|
||||||
VISIONA_OIDC_PROMPT_LOGIN=false
|
|
||||||
|
|
||||||
# Phase 0.8b 移除:VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
|
# Phase 0.8b 移除:VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
|
||||||
# 服務間認證從 OAuth client_credentials 改為 pre-shared API key(見 ADR-015、conversion.md §3)。
|
# 服務間認證從 OAuth client_credentials 改為 pre-shared API key(見 ADR-015、conversion.md §3)。
|
||||||
# 兩個 service client env 不再讀取(OIDCConfig.ServiceClientID/Secret struct 欄位
|
# 兩個 service client env 不再讀取(OIDCConfig.ServiceClientID/Secret struct 欄位
|
||||||
|
|||||||
@ -65,12 +65,6 @@ func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion.
|
|||||||
FileSize: rec.FileSize,
|
FileSize: rec.FileSize,
|
||||||
FileChecksum: rec.FileChecksum,
|
FileChecksum: rec.FileChecksum,
|
||||||
TargetChip: rec.TargetChip,
|
TargetChip: rec.TargetChip,
|
||||||
// B4 模型 metadata(optional):從 converter job analysis_info 串進來。
|
|
||||||
// 轉檔端尚未串好 analysis_info 時 rec.InputShape 等為零值(nil / ""),
|
|
||||||
// 這裡照樣賦值(賦 nil / "" 不影響建 model),DB 端寫 NULL / 空 INT[]。
|
|
||||||
InputShape: rec.InputShape,
|
|
||||||
Classes: rec.Classes,
|
|
||||||
Framework: rec.Framework,
|
|
||||||
Source: rec.Source, // 應為 "converted"
|
Source: rec.Source, // 應為 "converted"
|
||||||
SourceJobID: rec.SourceJobID,
|
SourceJobID: rec.SourceJobID,
|
||||||
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1:promote 寫入的 FAA object key
|
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1:promote 寫入的 FAA object key
|
||||||
@ -125,10 +119,6 @@ func modelToRecord(m *model.Model) *conversion.ModelRecord {
|
|||||||
FileSize: m.FileSize,
|
FileSize: m.FileSize,
|
||||||
FileChecksum: m.FileChecksum,
|
FileChecksum: m.FileChecksum,
|
||||||
TargetChip: m.TargetChip,
|
TargetChip: m.TargetChip,
|
||||||
// B4 模型 metadata(optional):冪等回傳時帶回,保持 round-trip 一致。
|
|
||||||
InputShape: m.InputShape,
|
|
||||||
Classes: m.Classes,
|
|
||||||
Framework: m.Framework,
|
|
||||||
Source: m.Source,
|
Source: m.Source,
|
||||||
SourceJobID: m.SourceJobID,
|
SourceJobID: m.SourceJobID,
|
||||||
FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1
|
FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1
|
||||||
|
|||||||
@ -1,372 +0,0 @@
|
|||||||
// conversion_adapters_test.go — conversionModelStoreAdapter 的單元測試。
|
|
||||||
//
|
|
||||||
// 聚焦 B4 metadata(InputShape / Classes / Framework)的雙向 round-trip:
|
|
||||||
// - Save:conversion.ModelRecord → model.Model 時 metadata 要帶過去(adapter 曾遺漏這段,
|
|
||||||
// 導致 flow.go 寫進 ModelRecord 的 input_shape 在 adapter 被吞掉、進不了 DB)
|
|
||||||
// - modelToRecord(透過 FindBySourceJobID):model.Model → conversion.ModelRecord 帶回
|
|
||||||
//
|
|
||||||
// 用 model.NewInMemoryRepository(非 DB),不需 dbtest tag;DB 端真正落地由 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_CarriesMetadata:Save 應把 ModelRecord 的 B4 metadata
|
|
||||||
// 帶進底層 model.Model(adapter 曾漏掉 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.Model(NCHW)")
|
|
||||||
assert.Equal(t, []string{"face", "person"}, got.Classes)
|
|
||||||
assert.Equal(t, "onnx", got.Framework)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConversionAdapter_Save_NilMetadata_OK:metadata 為零值(轉檔端尚未串好)→
|
|
||||||
// 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_RoundTripsMetadata:modelToRecord 應把
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save:UpdatedAt 為零值時 UploadedAt fallback 到 now(promote 完即 ready)。
|
|
||||||
// 對齊 toModelResponse:UploadedAt 非 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 應 <= 呼叫後時間")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save:UpdatedAt 非零時 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 ───────────────────────
|
|
||||||
|
|
||||||
// FindBySourceJobID:empty args(ownerUserID 或 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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindBySourceJobID:repo 有資料但無 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、存在的 job(owner 過濾應使其找不到)
|
|
||||||
rec2, err := adapter.FindBySourceJobID(context.Background(), "user-other", "j-exists")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, rec2, "不同 owner 不應 match(List 已用 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.Put:happy —— 透傳 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.Put:error 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 應原樣往上傳")
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
// 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,驗證:
|
|
||||||
// - 問題 #1:OIDC callback 成功後 user 被 provision 進 UserStore。
|
|
||||||
// - 問題 #2:pairing 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 驗證問題 #1:OIDC 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 flow(callback 會 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 驗證問題 #2:pairing exchange 後自建 device(owner 對齊)。
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
@ -1,309 +0,0 @@
|
|||||||
//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`:需要 Docker(PG 容器)。本機無 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 路徑。
|
|
||||||
// (此差異已回報 Orchestrator:HTTP 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 應含剛上傳的 model(DB-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 token(MarkUsed 綁 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 /unpair:unpair 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(綁 device):Create → 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(綁 device):device_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. Unpair(Postgres tx:device 軟刪 + 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
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
//go:build dbtest
|
|
||||||
|
|
||||||
// fixture_dbon_test.go — 塊 6 完整版(6.5 e2e 回歸):setupFixture 的 DB-on 變體。
|
|
||||||
//
|
|
||||||
// 既有 integration / e2e(integration_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 接入塊 0–5 把 6 個 store 都接上 Postgres / Redis
|
|
||||||
// 後,必須證明「同一套 handler、改注入 PG/Redis store,e2e 仍跑得通」,這就是本檔提供的
|
|
||||||
// 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 而非真 Redis:miniredis 在 process 內跑、無 Docker / 無網路需求,
|
|
||||||
// 與真 RedisUserSessionStore 走同一條 go-redis client 介面(main.go 也是注入 go-redis client)。
|
|
||||||
// 真 Redis(130)行為已由 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(必須是合法 UUID)upsert 一筆 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
|
|
||||||
// 改注入 Postgres(testcontainers)+ Redis(miniredis)。
|
|
||||||
//
|
|
||||||
// 與 in-memory 版的唯一差別在 store 注入;handler / router / OIDC / tunnel 完全相同,
|
|
||||||
// 證明「介面不變、只換後端」的塊 0–5 設計目標在 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-proxy(tunnel session store 仍 in-memory:yamux 連線註冊表,本來就不落 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 session:Redis(miniredis)。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 cascade(Postgres 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -51,7 +51,6 @@ import (
|
|||||||
"visiona-backend/internal/relay"
|
"visiona-backend/internal/relay"
|
||||||
"visiona-backend/internal/session"
|
"visiona-backend/internal/session"
|
||||||
"visiona-backend/internal/storage"
|
"visiona-backend/internal/storage"
|
||||||
"visiona-backend/internal/user"
|
|
||||||
"visiona-backend/internal/usersession"
|
"visiona-backend/internal/usersession"
|
||||||
"visiona-backend/internal/wsconn"
|
"visiona-backend/internal/wsconn"
|
||||||
)
|
)
|
||||||
@ -108,12 +107,6 @@ type testFixture struct {
|
|||||||
pairingStore *auth.InMemoryPairingStore
|
pairingStore *auth.InMemoryPairingStore
|
||||||
sessionMgr *usersession.Manager
|
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
|
// router 暴露 *gin.Engine 給需要列出所有 route 的 test
|
||||||
// (目前用於 all_endpoints_require_auth_test.go — Phase 0.7 security regression)。
|
// (目前用於 all_endpoints_require_auth_test.go — Phase 0.7 security regression)。
|
||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
@ -218,17 +211,14 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
|
|||||||
})
|
})
|
||||||
|
|
||||||
pairingStore := auth.NewInMemoryPairingStore()
|
pairingStore := auth.NewInMemoryPairingStore()
|
||||||
sessionTokenStore := auth.NewInMemorySessionTokenStore()
|
|
||||||
deviceRepo := device.NewInMemoryRepository()
|
|
||||||
userStore := user.NewInMemoryStore()
|
|
||||||
|
|
||||||
router := api.NewRouter(api.Deps{
|
router := api.NewRouter(api.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
PairingStore: pairingStore,
|
PairingStore: pairingStore,
|
||||||
SessionTokenStore: sessionTokenStore,
|
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
Forwarder: forwarder,
|
Forwarder: forwarder,
|
||||||
DeviceRepo: deviceRepo,
|
DeviceRepo: device.NewInMemoryRepository(),
|
||||||
ModelRepo: model.NewInMemoryRepository(),
|
ModelRepo: model.NewInMemoryRepository(),
|
||||||
Storage: storeStore,
|
Storage: storeStore,
|
||||||
Converter: converter.NewStubClient(),
|
Converter: converter.NewStubClient(),
|
||||||
@ -237,10 +227,6 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
|
|||||||
MaxUploadSizeMB: maxUploadMB,
|
MaxUploadSizeMB: maxUploadMB,
|
||||||
RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL
|
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 wiring(OB5)
|
// OIDC wiring(OB5)
|
||||||
OIDCProvider: oidcProvider,
|
OIDCProvider: oidcProvider,
|
||||||
SessionManager: sessionMgr,
|
SessionManager: sessionMgr,
|
||||||
@ -258,8 +244,6 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
|
|||||||
fakeOIDC: fakeOIDC,
|
fakeOIDC: fakeOIDC,
|
||||||
pairingStore: pairingStore,
|
pairingStore: pairingStore,
|
||||||
sessionMgr: sessionMgr,
|
sessionMgr: sessionMgr,
|
||||||
userStore: userStore,
|
|
||||||
deviceRepo: deviceRepo,
|
|
||||||
router: router,
|
router: router,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"visiona-backend/internal/api"
|
"visiona-backend/internal/api"
|
||||||
"visiona-backend/internal/auth"
|
"visiona-backend/internal/auth"
|
||||||
@ -37,7 +38,6 @@ import (
|
|||||||
"visiona-backend/internal/oidc"
|
"visiona-backend/internal/oidc"
|
||||||
"visiona-backend/internal/session"
|
"visiona-backend/internal/session"
|
||||||
"visiona-backend/internal/storage"
|
"visiona-backend/internal/storage"
|
||||||
"visiona-backend/internal/user"
|
|
||||||
"visiona-backend/internal/usersession"
|
"visiona-backend/internal/usersession"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -173,7 +173,6 @@ func main() {
|
|||||||
ClientID: cfg.OIDC.ClientID,
|
ClientID: cfg.OIDC.ClientID,
|
||||||
ClientSecret: cfg.OIDC.ClientSecret,
|
ClientSecret: cfg.OIDC.ClientSecret,
|
||||||
RedirectURL: cfg.OIDC.RedirectURL,
|
RedirectURL: cfg.OIDC.RedirectURL,
|
||||||
PromptLogin: cfg.OIDC.PromptLogin,
|
|
||||||
})
|
})
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -211,8 +210,6 @@ func main() {
|
|||||||
"client_id", cfg.OIDC.ClientID,
|
"client_id", cfg.OIDC.ClientID,
|
||||||
"redirect_url", cfg.OIDC.RedirectURL,
|
"redirect_url", cfg.OIDC.RedirectURL,
|
||||||
"frontend_url", cfg.OIDC.PostLoginURL,
|
"frontend_url", cfg.OIDC.PostLoginURL,
|
||||||
"logout_url", cfg.OIDC.LogoutURL, // 空=logout 不連動 MC
|
|
||||||
"prompt_login", cfg.OIDC.PromptLogin,
|
|
||||||
"cookie_secure", cfg.UserSession.CookieSecure,
|
"cookie_secure", cfg.UserSession.CookieSecure,
|
||||||
"absolute_ttl", cfg.UserSession.AbsoluteTTL,
|
"absolute_ttl", cfg.UserSession.AbsoluteTTL,
|
||||||
"idle_ttl", cfg.UserSession.IdleTTL,
|
"idle_ttl", cfg.UserSession.IdleTTL,
|
||||||
@ -268,33 +265,6 @@ func main() {
|
|||||||
log.Info("model repository initialized", "backend", "in-memory")
|
log.Info("model repository initialized", "backend", "in-memory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// user:DB-on FK 收尾(問題 #1)— OIDC callback provision users 列用。
|
|
||||||
// dbPool != nil 時切到 PostgresStore;否則 in-memory(local-dev fallback)。
|
|
||||||
// D1-B:OIDC sub 直接當 users.id(Member 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 exchanger:DB-on FK 收尾(問題 #2)— exchange 時自建 device + 建 session token。
|
|
||||||
// - Postgres:db.WithTx 把「建 device + 建 session token」包成單一交易,整筆原子。
|
|
||||||
// - in-memory:依序執行(無交易),行為一致。
|
|
||||||
// 注入 Deps.PairingExchanger;exchange 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Converter(stub,Phase 2 才實作) =====
|
// ===== Converter(stub,Phase 2 才實作) =====
|
||||||
converterClient := converter.NewStubClient()
|
converterClient := converter.NewStubClient()
|
||||||
|
|
||||||
@ -386,9 +356,13 @@ func main() {
|
|||||||
|
|
||||||
// ===== Seed demo data(可選) =====
|
// ===== Seed demo data(可選) =====
|
||||||
if cfg.Server.SeedDemoData {
|
if cfg.Server.SeedDemoData {
|
||||||
// dbPool 非 nil 時,seed 的 model/device 走 Postgres(塊 1+):seedDemoData 內部會先透過
|
// dbPool 非 nil 時,seed 的 model 走 Postgres(塊 1):seedDemoData 內部會先 ensure
|
||||||
// userStore.Upsert ensure demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。
|
// demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。
|
||||||
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, userStore, cfg.Auth.StaticUserID, dbPool != nil, log); err != nil {
|
var seedPool *pgxpool.Pool
|
||||||
|
if dbPool != nil {
|
||||||
|
seedPool = dbPool.Pool()
|
||||||
|
}
|
||||||
|
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, seedPool, log); err != nil {
|
||||||
log.Warn("seed demo data failed", "error", err)
|
log.Warn("seed demo data failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -418,7 +392,6 @@ func main() {
|
|||||||
DeviceRepo: deviceRepo,
|
DeviceRepo: deviceRepo,
|
||||||
ModelRepo: modelRepo,
|
ModelRepo: modelRepo,
|
||||||
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序)
|
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序)
|
||||||
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2:exchange 自建 device + session token
|
|
||||||
Storage: storageStore,
|
Storage: storageStore,
|
||||||
Converter: converterClient,
|
Converter: converterClient,
|
||||||
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
|
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
|
||||||
@ -436,8 +409,6 @@ func main() {
|
|||||||
OIDCProvider: oidcProvider,
|
OIDCProvider: oidcProvider,
|
||||||
SessionManager: userSessionMgr,
|
SessionManager: userSessionMgr,
|
||||||
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
|
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
|
||||||
OIDCLogoutURL: cfg.OIDC.LogoutURL, // 空=logout 只清本地、不連動 MC(向下相容)
|
|
||||||
UserStore: userStore, // DB-on FK 收尾 #1:OIDC callback provision users 列
|
|
||||||
})
|
})
|
||||||
|
|
||||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||||
|
|||||||
@ -6,11 +6,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"visiona-backend/internal/auth"
|
"visiona-backend/internal/auth"
|
||||||
"visiona-backend/internal/device"
|
"visiona-backend/internal/device"
|
||||||
"visiona-backend/internal/model"
|
"visiona-backend/internal/model"
|
||||||
"visiona-backend/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。
|
// demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。
|
||||||
@ -34,20 +34,15 @@ const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3"
|
|||||||
// - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證)
|
// - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證)
|
||||||
// - **不要**在生產環境啟用此 flag
|
// - **不要**在生產環境啟用此 flag
|
||||||
//
|
//
|
||||||
// dbBacked 為 true 表 repository 已切到 Postgres(塊 1+):此時 seed 的 model/device 必須用
|
// dbPool 非 nil 表 model repo 已切到 Postgres(塊 1):此時 seed 的 model 必須用合法 UUID
|
||||||
// 合法 UUID 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。
|
// 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。
|
||||||
// dbBacked 為 false(in-memory fallback)時行為與雛形完全相同。
|
// dbPool 為 nil(in-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(
|
func seedDemoData(
|
||||||
devRepo device.Repository,
|
devRepo device.Repository,
|
||||||
mdlRepo model.Repository,
|
mdlRepo model.Repository,
|
||||||
pairings auth.PairingStore,
|
pairings auth.PairingStore,
|
||||||
userStore user.Store,
|
|
||||||
userID string,
|
userID string,
|
||||||
dbBacked bool,
|
dbPool *pgxpool.Pool,
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
) error {
|
) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@ -66,19 +61,17 @@ func seedDemoData(
|
|||||||
pairingOwnerID := userID
|
pairingOwnerID := userID
|
||||||
modelID := "demo-model-" + uuid.NewString()[:8]
|
modelID := "demo-model-" + uuid.NewString()[:8]
|
||||||
deviceID := "demo-device-" + uuid.NewString()[:8]
|
deviceID := "demo-device-" + uuid.NewString()[:8]
|
||||||
if dbBacked {
|
if dbPool != nil {
|
||||||
modelOwnerID = demoSeedUserID
|
modelOwnerID = demoSeedUserID
|
||||||
deviceOwnerID = demoSeedUserID
|
deviceOwnerID = demoSeedUserID
|
||||||
pairingOwnerID = demoSeedUserID
|
pairingOwnerID = demoSeedUserID
|
||||||
modelID = uuid.NewString()
|
modelID = uuid.NewString()
|
||||||
deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串
|
deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串
|
||||||
// 先確保 owner user 存在(滿足 models / devices owner_user_id FK)。
|
// 先確保 owner user 存在(滿足 models / devices owner_user_id FK)。
|
||||||
// 走 userStore.Upsert(DB-on = PostgresStore)與 OIDC callback 同一條 provision 路徑。
|
if _, err := dbPool.Exec(ctx,
|
||||||
if err := userStore.Upsert(ctx, &user.User{
|
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
|
||||||
ID: demoSeedUserID,
|
ON CONFLICT (id) DO NOTHING`,
|
||||||
Email: "demo@visiona.local",
|
demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil {
|
||||||
Name: "Demo User (seeded)",
|
|
||||||
}); err != nil {
|
|
||||||
log.Warn("seed: ensure demo user failed", "error", err)
|
log.Warn("seed: ensure demo user failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
//go:build dbtest
|
|
||||||
|
|
||||||
// seed_db_test.go — seedDemoData 的 DB-backed(dbBacked=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_UpsertsUserAndSeedsAll:DB-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 已 upsert(owner 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. device:owner = 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. model:owner = 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 token:owner = 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_GeneratesUUIDIDs:DB-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")
|
|
||||||
}
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
// seed_test.go — seedDemoData 的 in-memory(dbBacked=false)unit test。
|
|
||||||
//
|
|
||||||
// Owner: testing agent(補測任務 — cmd/api-server 可測部分)
|
|
||||||
//
|
|
||||||
// 對象:seedDemoData。in-memory 分支不需 DB,純 unit。DB-backed(dbBacked=true)分支
|
|
||||||
// 需真 Postgres → 拆到 seed_db_test.go(//go:build dbtest,130 docker 跑)。
|
|
||||||
//
|
|
||||||
// 行為對齊 seed.go docstring:
|
|
||||||
// - dbBacked=false 用傳入 userID(StaticUserID 概念)當 owner,model/device id 帶隨機後綴。
|
|
||||||
// - 不 upsert demo user(in-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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// happy:in-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()
|
|
||||||
|
|
||||||
// device:owner = 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)
|
|
||||||
|
|
||||||
// model:owner = uid,StorageKey 用 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 應設 UploadedAt(ready)")
|
|
||||||
|
|
||||||
// pairing token:owner = 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(不需 FK);userStore 應維持空(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-ish:seedDemoData 每次呼叫產生獨立資料(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 個 device(id 帶隨機後綴、不去重)")
|
|
||||||
|
|
||||||
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 仍應回 nil(fail-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 }
|
|
||||||
@ -31,7 +31,6 @@ import (
|
|||||||
"visiona-backend/internal/oidc"
|
"visiona-backend/internal/oidc"
|
||||||
"visiona-backend/internal/session"
|
"visiona-backend/internal/session"
|
||||||
"visiona-backend/internal/storage"
|
"visiona-backend/internal/storage"
|
||||||
"visiona-backend/internal/user"
|
|
||||||
"visiona-backend/internal/usersession"
|
"visiona-backend/internal/usersession"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,26 +62,6 @@ type Deps struct {
|
|||||||
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
|
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
|
||||||
OIDCPostLoginURL string
|
OIDCPostLoginURL string
|
||||||
|
|
||||||
// OIDCLogoutURL 是「讓使用者連帶登出 IdP(Member Center)session」的入口 URL。
|
|
||||||
//
|
|
||||||
// 背景:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是
|
|
||||||
// MemberCenter.Web(:7880)的 /account/logout(GET 即 302)。詳見 config.OIDCConfig.LogoutURL。
|
|
||||||
//
|
|
||||||
// 非空時,logout handler(oidc_auth.go)會在 LogoutResponse 多回 idp_logout 欄位,
|
|
||||||
// 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。
|
|
||||||
// 對齊 cfg.OIDC.LogoutURL(env VISIONA_OIDC_LOGOUT_URL)。
|
|
||||||
OIDCLogoutURL string
|
|
||||||
|
|
||||||
// UserStore 在 OIDC callback 驗 id_token 成功後 provision(upsert)一筆 users 列
|
|
||||||
// (DB-on FK 收尾,問題 #1)。D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。
|
|
||||||
//
|
|
||||||
// 為何必須: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
|
SessionStore session.Store
|
||||||
Forwarder *session.Forwarder
|
Forwarder *session.Forwarder
|
||||||
|
|
||||||
@ -140,14 +119,6 @@ type Deps struct {
|
|||||||
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
|
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
|
||||||
RelayPublicURL string
|
RelayPublicURL string
|
||||||
|
|
||||||
// PairingExchanger 在 pairing exchange 時自建一筆 device 並建綁該 device 的 session token
|
|
||||||
// (DB-on FK 收尾,問題 #2:session_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)。
|
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴(DB 接入塊 5.4)。
|
||||||
// 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
|
// 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
|
||||||
// /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。
|
// /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。
|
||||||
|
|||||||
@ -99,28 +99,3 @@ func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) {
|
|||||||
|
|
||||||
WriteError(c, cls.status, cls.code, cls.message, nil)
|
WriteError(c, cls.status, cls.code, cls.message, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteStorageError 把一個 storage(物件儲存)操作錯誤映射成對外 API 錯誤並寫回 response,
|
|
||||||
// 同時把 raw error 進 server log(含 request_id)。
|
|
||||||
//
|
|
||||||
// 動機(塊 5 Minor-1):presigned URL / Stat 等 storage 操作失敗時,handler 過去把
|
|
||||||
// err.Error() 直接串進 response message,可能洩漏 storage 後端細節(bucket / endpoint /
|
|
||||||
// 內部路徑 / 簽章參數)給前端。本函式比照 WriteDBError,對外只給穩定 code + 通用 message,
|
|
||||||
// raw error 只進 server log。
|
|
||||||
//
|
|
||||||
// 對外一律 500 INTERNAL_ERROR:storage 後端不可用 / 設定錯誤對前端而言都是「伺服器端問題」,
|
|
||||||
// 不細分(與 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,172 +0,0 @@
|
|||||||
// errors_test.go — errors.go 3 個純 helper(WriteError / WriteSuccess / WriteNotImplemented)的 unit test。
|
|
||||||
//
|
|
||||||
// Owner: testing agent(補測任務 — internal/api 弱處純函式)
|
|
||||||
//
|
|
||||||
// 這 3 個函式直接寫 gin.Context 的 JSON response(envelope 形狀對齊 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.Context;reqID 非空時塞入 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 時應為 nil(omitempty)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundary:帶 details(validation 細節)時,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 slice(len 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"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// edge:data 為 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 省略")
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundary:non-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_IMPLEMENTED,hint 帶進 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)
|
|
||||||
}
|
|
||||||
@ -60,13 +60,6 @@ type ModelResponse struct {
|
|||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Status string `json:"status"` // "pending" / "ready"
|
Status string `json:"status"` // "pending" / "ready"
|
||||||
// 模型 metadata(B4 鏈路最後一個序列化點)。
|
|
||||||
// snake_case 對齊本 DTO 既有慣例(target_chip / file_size / created_at);
|
|
||||||
// 前端 model-store normalize 雙吃 snake/camel,input_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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
@ -86,9 +79,6 @@ func toModelResponse(m *model.Model) ModelResponse {
|
|||||||
FileSize: m.FileSize,
|
FileSize: m.FileSize,
|
||||||
Source: m.Source,
|
Source: m.Source,
|
||||||
Status: status,
|
Status: status,
|
||||||
InputShape: m.InputShape,
|
|
||||||
Classes: m.Classes,
|
|
||||||
Framework: m.Framework,
|
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
UpdatedAt: m.UpdatedAt,
|
UpdatedAt: m.UpdatedAt,
|
||||||
UploadedAt: m.UploadedAt,
|
UploadedAt: m.UploadedAt,
|
||||||
@ -254,8 +244,8 @@ func modelsInitUploadHandler(deps Deps) gin.HandlerFunc {
|
|||||||
// 產 presigned PUT URL
|
// 產 presigned PUT URL
|
||||||
uploadURL, err := deps.Storage.PresignedPutURL(ctx, storageKey, modelUploadURLTTL)
|
uploadURL, err := deps.Storage.PresignedPutURL(ctx, storageKey, modelUploadURLTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
WriteStorageError(c, deps.Logger, "presigned put url", err)
|
"presigned url failed: "+err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,8 +343,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
"file not uploaded yet; PUT to upload_url first", nil)
|
"file not uploaded yet; PUT to upload_url first", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
WriteStorageError(c, deps.Logger, "stat object", statErr)
|
"stat storage failed: "+statErr.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Size 驗證(雛形只比對 size;Phase 1 加 checksum)
|
// Size 驗證(雛形只比對 size;Phase 1 加 checksum)
|
||||||
|
|||||||
@ -183,122 +183,6 @@ func TestModelsDelete_NotOwner(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
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 endpoint(GET /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 的模型。
|
// TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。
|
||||||
func TestModelsList_FiltersByOwner(t *testing.T) {
|
func TestModelsList_FiltersByOwner(t *testing.T) {
|
||||||
r, repo, _ := newModelsFixture(t)
|
r, repo, _ := newModelsFixture(t)
|
||||||
|
|||||||
@ -32,7 +32,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"visiona-backend/internal/oidc"
|
"visiona-backend/internal/oidc"
|
||||||
"visiona-backend/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。
|
// oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。
|
||||||
@ -40,42 +39,6 @@ import (
|
|||||||
// 又不會讓 caller 端等到 default HTTP server timeout。
|
// 又不會讓 caller 端等到 default HTTP server timeout。
|
||||||
const oidcCallbackTimeout = 30 * time.Second
|
const oidcCallbackTimeout = 30 * time.Second
|
||||||
|
|
||||||
// fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。
|
|
||||||
//
|
|
||||||
// 背景:Member Center(MC)的 id_token 目前不發 email claim(ASP.NET Identity 預設只發
|
|
||||||
// sub/name)。A7 之後 OIDC callback 會把 claims provision 進 users 表,而 users.email 是
|
|
||||||
// NOT NULL(防呆檢查保留在 user store 層)。若 email 為空 → Upsert 失敗 → 登入 500。
|
|
||||||
//
|
|
||||||
// 修法:email claim 缺時,用 "<sub>@<fallbackEmailDomain>" 當 placeholder:
|
|
||||||
// - 用 sub 保證唯一(不撞 users 表 lower(email) unique index)
|
|
||||||
// - 用明顯假的 .local TLD(RFC 6762 保留、不可能是真 email)標記「這不是真 email」
|
|
||||||
//
|
|
||||||
// MC 端根治(讓 id_token 發真 email)交接給 MC 團隊,見
|
|
||||||
// docs/autoflow/04-architecture/mc-email-claim-handoff.md。MC 修好後,同一個 sub 再次登入,
|
|
||||||
// Upsert 的 ON CONFLICT(id) DO UPDATE 會把 placeholder 覆寫成真 email(無需手動清資料)。
|
|
||||||
const fallbackEmailDomain = "noemail.visiona.local"
|
|
||||||
|
|
||||||
// isFallbackEmail 回報 email 是否為本系統產生的 fallback placeholder(而非真 email)。
|
|
||||||
//
|
|
||||||
// 供 log / 後續資料盤點判斷「哪些 user 還是 fallback、待 MC 修好後自動覆寫」。
|
|
||||||
func isFallbackEmail(email string) bool {
|
|
||||||
return strings.HasSuffix(email, "@"+fallbackEmailDomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveProvisionEmail 決定 provision 進 users 表時要寫入的 email。
|
|
||||||
//
|
|
||||||
// - claimEmail 非空(MC 有發 / 未來修好)→ 直接用真 email、不套 fallback。
|
|
||||||
// - claimEmail 為空(MC 現況)→ 用 "<sub>@noemail.visiona.local" placeholder。
|
|
||||||
//
|
|
||||||
// 回傳 (email, isFallback);isFallback=true 時 caller 應 log 標記。
|
|
||||||
// sub 理論上不會為空(VerifyIDToken 已驗 sub),但防禦性地仍會組出合法 email 字串。
|
|
||||||
func resolveProvisionEmail(sub, claimEmail string) (email string, isFallback bool) {
|
|
||||||
if claimEmail != "" {
|
|
||||||
return claimEmail, false
|
|
||||||
}
|
|
||||||
return sub + "@" + fallbackEmailDomain, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。
|
// MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。
|
||||||
//
|
//
|
||||||
// 故意與 Legacy MeResponse 區分:OIDC 沒有 Roles 概念(雛形),但有 Name。
|
// 故意與 Legacy MeResponse 區分:OIDC 沒有 Roles 概念(雛形),但有 Name。
|
||||||
@ -86,45 +49,8 @@ type MeResponseOIDC struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LogoutResponse 是 POST /api/auth/logout 的 data payload。
|
// LogoutResponse 是 POST /api/auth/logout 的 data payload。
|
||||||
//
|
|
||||||
// IDPLogout 為**選填**:只有當 deps.OIDCLogoutURL 有設定時才回傳,告訴前端
|
|
||||||
// 「除了清掉 visionA session,還要連帶登出 IdP(Member Center)session」。
|
|
||||||
// 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。
|
|
||||||
type LogoutResponse struct {
|
type LogoutResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
|
|
||||||
// IDPLogout 帶 IdP(MC)登出資訊,供前端觸發 MC 登出(讓使用者能換帳號)。
|
|
||||||
// nil 時代表未啟用 MC 連動登出。
|
|
||||||
IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IDPLogoutInfo 描述「如何觸發 IdP(Member Center)登出」。
|
|
||||||
//
|
|
||||||
// 為什麼需要 Method:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是
|
|
||||||
// MemberCenter.**Web**(:7880)的 /account/logout。實測 GET 即回 302(清 session + redirect),
|
|
||||||
// 所以前端可直接 window.location = url 觸發,不必搞 form POST。故 Method="GET"。
|
|
||||||
//
|
|
||||||
// 注意(host):必須指向 MC **Web**(:7880),不是 MC **Api**(:7850)。:7850 沒有 logout 頁
|
|
||||||
// (AccountController.Logout 在 Web 端),打 :7850 會 404。MC Web/Api 共享 DataProtection
|
|
||||||
// (SetApplicationName("MemberCenter")),清 :7880 session 後 :7850 authorize 也視為未登入,
|
|
||||||
// 下次登入會問帳密、能換帳號。
|
|
||||||
//
|
|
||||||
// 流程交接(前端):拿到 IDPLogout 後 →
|
|
||||||
// 1. 先(或同時)打 visionA logout(本 response 已清本地 session)
|
|
||||||
// 2. window.location = URL(GET)觸發 MC :7880 登出
|
|
||||||
// 3. ⚠️ stage MC 跑 master 舊版(2026-04-30 image),logout **不支援 returnUrl**,
|
|
||||||
// 清完 session 會 RedirectToAction("Index","Home") 停在 MC 首頁、不會自動回 visionA。
|
|
||||||
// 前端需自行把使用者帶回 visionA 登入(GET /api/auth/login)。
|
|
||||||
//
|
|
||||||
// 注意(跨網域):MC /account/logout 在另一個 origin。GET 導向不受 antiforgery/CORS 限制
|
|
||||||
// (瀏覽器直接 navigate),比舊的跨站 form POST 單純。
|
|
||||||
type IDPLogoutInfo struct {
|
|
||||||
// URL 是 IdP(MC Web,:7880)的瀏覽器登出入口。例:
|
|
||||||
// https://stage-9527.innovedus.com:7880/account/logout
|
|
||||||
URL string `json:"url"`
|
|
||||||
|
|
||||||
// Method 是觸發登出要用的 HTTP method。MC Web(:7880) GET 即可觸發、固定為 "GET"。
|
|
||||||
Method string `json:"method"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。
|
// registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。
|
||||||
@ -302,47 +228,6 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision users 列(DB-on FK 收尾,問題 #1)。
|
|
||||||
//
|
|
||||||
// D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。upsert 後 users 表才有
|
|
||||||
// 這筆使用者,後續帶 owner_user_id FK 的寫入(model 上傳 / 配對 / pairing token)才不會
|
|
||||||
// FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK,僅維持對稱。
|
|
||||||
//
|
|
||||||
// 在「驗 id_token 成功後、寫 session 之前」provision:fail-closed —— provision 失敗就不發
|
|
||||||
// session(否則使用者拿到能登入的 cookie 但 DB 沒對應 user,下一個寫入照樣爆,且更難診斷)。
|
|
||||||
// UserStore 為 nil(最小骨架 / 純 OIDC unit test)→ 略過 upsert。
|
|
||||||
//
|
|
||||||
// email fallback(放寬 email 必填):MC 現況不發 email claim,claims.Email 為空。
|
|
||||||
// users.email NOT NULL 的防呆檢查保留在 user store 層;這裡在 provision 前先把空 email
|
|
||||||
// 補成 "<sub>@noemail.visiona.local" placeholder,讓登入能成功。MC 修好後同 sub 再登入,
|
|
||||||
// Upsert ON CONFLICT(id) DO UPDATE 會自動把 placeholder 覆寫成真 email。
|
|
||||||
provisionEmail, emailIsFallback := resolveProvisionEmail(claims.Subject, claims.Email)
|
|
||||||
if emailIsFallback {
|
|
||||||
// log 標記哪些 user 是 fallback,方便盤點「待 MC 修好後覆寫」的數量。
|
|
||||||
// 不 log email 內容(雖是 placeholder 無敏感性,仍保守只記 sub)。
|
|
||||||
log.Warn("oidc.callback: email claim missing, using fallback placeholder",
|
|
||||||
"request_id", RequestIDFrom(c),
|
|
||||||
"action", "oidc.callback.email_fallback",
|
|
||||||
"user_id", claims.Subject,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if deps.UserStore != nil {
|
|
||||||
if upErr := deps.UserStore.Upsert(ctx, &user.User{
|
|
||||||
ID: claims.Subject, // = users.id(D1-B)
|
|
||||||
Email: provisionEmail,
|
|
||||||
Name: claims.Name,
|
|
||||||
}); upErr != nil {
|
|
||||||
// 不洩漏 raw error 給 user;log 留診斷(不含 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。
|
// Session fixation 防護(OWASP ASVS V3.2.1)— Fix-A1 / Major-1。
|
||||||
//
|
//
|
||||||
// 在「驗 id_token 成功後、寫使用者 info 進 session 之前」rotate session ID。
|
// 在「驗 id_token 成功後、寫使用者 info 進 session 之前」rotate session ID。
|
||||||
@ -440,19 +325,9 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
|
|||||||
|
|
||||||
// oidcLogoutHandler 實作 POST /api/auth/logout(OIDC 模式)。
|
// oidcLogoutHandler 實作 POST /api/auth/logout(OIDC 模式)。
|
||||||
//
|
//
|
||||||
// 行為:一律清掉 visionA 自己的 session + cookie(原有行為,不變)。
|
// 雛形不做 RP-initiated logout(不通知 IdP)— 只清本地 session + cookie。
|
||||||
// Idempotent:cookie 不存在或 session 已清也回 200。
|
// Idempotent:cookie 不存在或 session 已清也回 200。
|
||||||
//
|
//
|
||||||
// MC 連動登出(2026-06 新增):MC **不支援標準 OIDC RP-initiated logout**
|
|
||||||
// (discovery 列的 end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client
|
|
||||||
// 未註冊 post_logout_redirect_uri),所以後端無法乾淨地 302 導向 MC end_session。
|
|
||||||
// 改採權宜方案:若 deps.OIDCLogoutURL 有設定,response 多回 idp_logout 欄位
|
|
||||||
// (MC Web :7880 /account/logout 的 GET 入口),由前端 navigate 觸發 MC 登出,
|
|
||||||
// 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。
|
|
||||||
//
|
|
||||||
// TODO:MC 補上標準 RP-initiated logout(GET end_session_endpoint + id_token_hint +
|
|
||||||
// post_logout_redirect_uri)後,改回後端 302 導向標準流程、移除這個 idp_logout 權宜欄位。
|
|
||||||
//
|
|
||||||
// 對齊 oidc-tdd.md §3.3。
|
// 對齊 oidc-tdd.md §3.3。
|
||||||
func oidcLogoutHandler(deps Deps) gin.HandlerFunc {
|
func oidcLogoutHandler(deps Deps) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@ -471,22 +346,12 @@ func oidcLogoutHandler(deps Deps) gin.HandlerFunc {
|
|||||||
"request_id", RequestIDFrom(c), "error", err)
|
"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",
|
log.Info("oidc.logout",
|
||||||
"request_id", RequestIDFrom(c),
|
"request_id", RequestIDFrom(c),
|
||||||
"action", "oidc.logout",
|
"action", "oidc.logout",
|
||||||
"user_id", userID,
|
"user_id", userID,
|
||||||
"idp_logout", deps.OIDCLogoutURL != "", // 不記 URL 本身(非機密但精簡);只記是否連動
|
|
||||||
)
|
)
|
||||||
WriteSuccess(c, http.StatusOK, resp)
|
WriteSuccess(c, http.StatusOK, LogoutResponse{Success: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,3 +402,4 @@ func sanitizeReturnTo(raw string) string {
|
|||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"visiona-backend/internal/oidc"
|
"visiona-backend/internal/oidc"
|
||||||
"visiona-backend/internal/user"
|
|
||||||
"visiona-backend/internal/usersession"
|
"visiona-backend/internal/usersession"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -411,123 +410,6 @@ func TestOIDCCallback_VerifyFails(t *testing.T) {
|
|||||||
assert.Contains(t, cbW.Body.String(), "id_token verification failed")
|
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 ----
|
// ---- TESTS: AuthMiddleware (OIDC 模式) + /api/auth/me + /api/auth/logout ----
|
||||||
|
|
||||||
// TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。
|
// TestOIDCMiddleware_Allows_AuthenticatedSession 驗證已登入 session 通過 + me 回 user info。
|
||||||
@ -630,84 +512,6 @@ func TestOIDCLogout_ClearsSession(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusUnauthorized, meW.Code)
|
assert.Equal(t, http.StatusUnauthorized, meW.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset 驗證未設定 OIDCLogoutURL 時,
|
|
||||||
// logout response 不含 idp_logout 欄位(向下相容,只清本地 session)。
|
|
||||||
func TestOIDCLogout_NoIDPLogout_WhenLogoutURLUnset(t *testing.T) {
|
|
||||||
provider := &mockOIDCProvider{}
|
|
||||||
deps := newOIDCTestDeps(provider)
|
|
||||||
// 不設 OIDCLogoutURL(預設空字串)
|
|
||||||
require.Empty(t, deps.OIDCLogoutURL)
|
|
||||||
r := newOIDCRouter(deps)
|
|
||||||
|
|
||||||
cookies := loginAndCallback(t, r, deps, provider)
|
|
||||||
|
|
||||||
logoutW := httptest.NewRecorder()
|
|
||||||
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
|
||||||
for _, c := range cookies {
|
|
||||||
logoutReq.AddCookie(c)
|
|
||||||
}
|
|
||||||
r.ServeHTTP(logoutW, logoutReq)
|
|
||||||
require.Equal(t, http.StatusOK, logoutW.Code)
|
|
||||||
|
|
||||||
// data.success == true 且不含 idp_logout
|
|
||||||
var env struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
|
|
||||||
assert.True(t, env.Data.Success)
|
|
||||||
assert.Nil(t, env.Data.IDPLogout, "未設定 OIDCLogoutURL 時不應回 idp_logout")
|
|
||||||
// 原始 JSON 不應出現 idp_logout key(omitempty 驗證)
|
|
||||||
assert.NotContains(t, logoutW.Body.String(), "idp_logout")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet 驗證設定 OIDCLogoutURL 後,
|
|
||||||
// logout response 含 idp_logout(URL + Method=GET),且仍清本地 session。
|
|
||||||
func TestOIDCLogout_IncludesIDPLogout_WhenLogoutURLSet(t *testing.T) {
|
|
||||||
provider := &mockOIDCProvider{}
|
|
||||||
deps := newOIDCTestDeps(provider)
|
|
||||||
// MC Web :7880 logout 入口(不是 :7850 Api)。
|
|
||||||
const mcLogoutURL = "https://stage-9527.innovedus.com:7880/account/logout"
|
|
||||||
deps.OIDCLogoutURL = mcLogoutURL
|
|
||||||
r := newOIDCRouter(deps)
|
|
||||||
|
|
||||||
cookies := loginAndCallback(t, r, deps, provider)
|
|
||||||
|
|
||||||
logoutW := httptest.NewRecorder()
|
|
||||||
logoutReq := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
|
||||||
for _, c := range cookies {
|
|
||||||
logoutReq.AddCookie(c)
|
|
||||||
}
|
|
||||||
r.ServeHTTP(logoutW, logoutReq)
|
|
||||||
require.Equal(t, http.StatusOK, logoutW.Code)
|
|
||||||
|
|
||||||
var env struct {
|
|
||||||
Data struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
IDPLogout *IDPLogoutInfo `json:"idp_logout"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.Unmarshal(logoutW.Body.Bytes(), &env))
|
|
||||||
assert.True(t, env.Data.Success)
|
|
||||||
require.NotNil(t, env.Data.IDPLogout, "設定 OIDCLogoutURL 後應回 idp_logout")
|
|
||||||
assert.Equal(t, mcLogoutURL, env.Data.IDPLogout.URL)
|
|
||||||
assert.Equal(t, http.MethodGet, env.Data.IDPLogout.Method)
|
|
||||||
|
|
||||||
// 本地 session 仍要被清掉(清 cookie)
|
|
||||||
var cleared *http.Cookie
|
|
||||||
for _, c := range logoutW.Result().Cookies() {
|
|
||||||
if c.Name == "visiona_session" {
|
|
||||||
cleared = c
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.NotNil(t, cleared, "expected visiona_session clearing cookie")
|
|
||||||
assert.True(t, cleared.MaxAge < 0, "expected MaxAge < 0 to clear cookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。
|
// TestOIDC_LegacyLogin_Returns410 驗證 OIDC 模式下 POST /api/auth/login 回 410。
|
||||||
func TestOIDC_LegacyLogin_Returns410(t *testing.T) {
|
func TestOIDC_LegacyLogin_Returns410(t *testing.T) {
|
||||||
provider := &mockOIDCProvider{}
|
provider := &mockOIDCProvider{}
|
||||||
|
|||||||
@ -421,43 +421,11 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自建 device + 建綁該 device 的 session token(DB-on FK 收尾,問題 #2)。
|
// Generate Session Token
|
||||||
//
|
plaintext, sessionInfo, err := deps.SessionTokenStore.Create(
|
||||||
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
|
|
||||||
// 改由 PairingExchanger 在 exchange 時自建一筆 device(owner = info.UserID,該 user 已透過
|
|
||||||
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
|
|
||||||
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback)。
|
|
||||||
//
|
|
||||||
// info.TokenHash 作為 parent_token_hash(稽核鏈:session token ← pairing token)。
|
|
||||||
//
|
|
||||||
// PairingExchanger 為 nil(DB-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,
|
ctx,
|
||||||
info.UserID,
|
info.UserID,
|
||||||
info.DeviceID, // fallback:雛形未綁 device_id,為空(僅 in-memory 可接受)
|
info.DeviceID, // Pairing Token 雛形還沒綁 device_id,為空沒關係
|
||||||
info.TokenHash,
|
info.TokenHash,
|
||||||
auth.SessionTokenTTL,
|
auth.SessionTokenTTL,
|
||||||
)
|
)
|
||||||
@ -469,8 +437,6 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
"failed to create session token", nil)
|
"failed to create session token", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceID = info.DeviceID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark pairing token as used。
|
// Mark pairing token as used。
|
||||||
//
|
//
|
||||||
@ -479,9 +445,9 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
// 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500,
|
// 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500,
|
||||||
// 而不是 silent log warn 繼續往前。
|
// 而不是 silent log warn 繼續往前。
|
||||||
//
|
//
|
||||||
// 注意:deviceID 現在綁的是自建 device(DB-on)或 info.DeviceID(fallback)。
|
// 注意:deviceID 沿用 info.DeviceID(可能為空)。雛形 MarkUsed 對空字串
|
||||||
// MarkUsed 把 pairing token 也綁上同一個 device_id(稽核:哪台 device 用掉了這個 token)。
|
// 是安全的(它只是覆寫欄位)。
|
||||||
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, deviceID); err != nil {
|
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.DeviceID); err != nil {
|
||||||
// 嘗試 revoke 剛產生的 session token;revoke 自身失敗不再 retry,只 log。
|
// 嘗試 revoke 剛產生的 session token;revoke 自身失敗不再 retry,只 log。
|
||||||
revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext)
|
revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext)
|
||||||
logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted",
|
logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted",
|
||||||
@ -489,7 +455,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
"revoke_err", revokeErr,
|
"revoke_err", revokeErr,
|
||||||
"token_prefix", tokenPrefix(req.PairingToken),
|
"token_prefix", tokenPrefix(req.PairingToken),
|
||||||
"session_token_prefix", tokenPrefix(plaintext),
|
"session_token_prefix", tokenPrefix(plaintext),
|
||||||
"device_id", deviceID,
|
"device_id", info.DeviceID,
|
||||||
"request_id", RequestIDFrom(c))
|
"request_id", RequestIDFrom(c))
|
||||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
"pairing token mark-used failed; aborted", nil)
|
"pairing token mark-used failed; aborted", nil)
|
||||||
@ -516,7 +482,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
// 故意只 log token prefix,避免完整 session token 進日誌
|
// 故意只 log token prefix,避免完整 session token 進日誌
|
||||||
logOrDefault(deps.Logger).Info("pairing exchange: success",
|
logOrDefault(deps.Logger).Info("pairing exchange: success",
|
||||||
"user_id", info.UserID,
|
"user_id", info.UserID,
|
||||||
"device_id", deviceID,
|
"device_id", info.DeviceID,
|
||||||
"session_token_prefix", tokenPrefix(plaintext),
|
"session_token_prefix", tokenPrefix(plaintext),
|
||||||
"pairing_token_prefix", tokenPrefix(req.PairingToken),
|
"pairing_token_prefix", tokenPrefix(req.PairingToken),
|
||||||
"request_id", RequestIDFrom(c))
|
"request_id", RequestIDFrom(c))
|
||||||
|
|||||||
@ -1,210 +0,0 @@
|
|||||||
// pairing_exchange.go — pairing exchange 自建 device 的協調者(DB-on FK 收尾,問題 #2)。
|
|
||||||
//
|
|
||||||
// 背景:
|
|
||||||
//
|
|
||||||
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
|
|
||||||
// production 路徑會建 device(grep 確認 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):
|
|
||||||
// - 讓 handler(pairing.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;任一步失敗回 error(handler 經 errors.go
|
|
||||||
// 映射成 5xx,不洩漏 raw error)。
|
|
||||||
type PairingExchanger interface {
|
|
||||||
// Provision 自建一筆 device(owner = 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。
|
|
||||||
//
|
|
||||||
// 任一步失敗整筆 rollback(device 不會「已建但沒 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
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
//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 成功:自建一筆 device(owner 對齊)+ 建綁該 device 的 session token,session
|
|
||||||
// token 的 device_id 不再為空、且確實指向新建的 device(FK 滿足)。
|
|
||||||
// - parent_token_hash 寫入(稽核鏈)。
|
|
||||||
// - 原子性:device owner 不存在(FK violation)→ 整筆 rollback,device 不會殘留。
|
|
||||||
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 不存在於 users(FK violation)
|
|
||||||
// → device INSERT 撞 owner_user_id FK → 整筆 rollback,device 不殘留。
|
|
||||||
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 應不同")
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -1,307 +0,0 @@
|
|||||||
// storage_test.go — /storage/* presigned 代理 handler 的 unit test。
|
|
||||||
//
|
|
||||||
// Owner: testing agent(補測任務 — internal/api 弱處 handler)
|
|
||||||
//
|
|
||||||
// 測試對象:storage.go 的 storageGetHandler / storagePutHandler / verifyStorageSignature /
|
|
||||||
// storageKeyFromPath / registerStorageRoutes。
|
|
||||||
//
|
|
||||||
// 策略:用真 *storage.LocalFSStore(t.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 engine(HMAC 控管、不經 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 / signature(mode 不影響 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 happy(round-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 簽章不能用於 PUT(method 綁進簽章 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:即使帶 query,verifyStorageSignature 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)
|
|
||||||
}
|
|
||||||
@ -57,17 +57,6 @@ const sessionColumns = `token_hash, user_id, device_id, parent_token_hash,
|
|||||||
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。
|
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。
|
||||||
func (s *PostgresSessionTokenStore) Create(
|
func (s *PostgresSessionTokenStore) Create(
|
||||||
ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration,
|
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 相同的語意,但在傳入的 Querier(pool 或 tx)上執行。
|
|
||||||
//
|
|
||||||
// 用於 pairing exchange 自建 device 時,與 device 建立在同一交易內(pairing 收尾問題 #2):
|
|
||||||
// 「建 device + 建 session token」整筆原子。q 可為 *pgxpool.Pool(自動 commit)或 pgx.Tx
|
|
||||||
// (隨外層交易)。device_id NOT NULL(session token 必綁 device),caller 須先在同 tx 建好 device。
|
|
||||||
func (s *PostgresSessionTokenStore) CreateTx(
|
|
||||||
ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration,
|
|
||||||
) (string, *SessionToken, error) {
|
) (string, *SessionToken, error) {
|
||||||
plaintext, err := GenerateSessionToken()
|
plaintext, err := GenerateSessionToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -97,10 +86,10 @@ func (s *PostgresSessionTokenStore) CreateTx(
|
|||||||
parentArg = parentTokenHash
|
parentArg = parentTokenHash
|
||||||
}
|
}
|
||||||
|
|
||||||
const sql = `INSERT INTO session_tokens
|
const q = `INSERT INTO session_tokens
|
||||||
(token_hash, user_id, device_id, parent_token_hash, created_at, expires_at)
|
(token_hash, user_id, device_id, parent_token_hash, created_at, expires_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`
|
VALUES ($1, $2, $3, $4, $5, $6)`
|
||||||
if _, err := q.Exec(ctx, sql,
|
if _, err := s.pool.Exec(ctx, q,
|
||||||
info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt,
|
info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", nil, fmt.Errorf("auth: pg session Create: %w", err)
|
return "", nil, fmt.Errorf("auth: pg session Create: %w", err)
|
||||||
|
|||||||
@ -304,16 +304,10 @@ func TestPGSession_ContextCancel(t *testing.T) {
|
|||||||
assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error")
|
assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tdbCountActive 直接 SELECT count(*) 該 owner 在 session_tokens 表中殘留的列數。
|
// tdbCountActive 計算某 owner 仍未過期(CleanupExpired 後殘留)的 session token 數。
|
||||||
//
|
// 用 CleanupExpired 後再跑一次 0-removed 的方式間接驗證殘留數。
|
||||||
// 呼叫端固定在 CleanupExpired 之後使用:過期列已被刪除,故殘留列數即「未過期列數」。
|
|
||||||
// 這是直接查 DB 殘留量,不另跑 CleanupExpired。
|
|
||||||
//
|
|
||||||
// 參數 deviceID 目前未用於查詢(殘留量以 owner 為準即可),保留於簽章以利日後若需
|
|
||||||
// 收斂到單一 device 時擴充查詢,不更動呼叫端。
|
|
||||||
func tdbCountActive(ctx context.Context, t *testing.T, s *PostgresSessionTokenStore, owner, deviceID string) int {
|
func tdbCountActive(ctx context.Context, t *testing.T, s *PostgresSessionTokenStore, owner, deviceID string) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_ = deviceID // 見上方註解:目前以 owner 為計數範圍。
|
|
||||||
var n int
|
var n int
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`SELECT count(*) FROM session_tokens WHERE user_id = $1`, owner).Scan(&n)
|
`SELECT count(*) FROM session_tokens WHERE user_id = $1`, owner).Scan(&n)
|
||||||
|
|||||||
@ -118,41 +118,6 @@ type OIDCConfig struct {
|
|||||||
// 對齊 VISIONA_FRONTEND_URL(沿用 oidc-tdd.md §13.1 命名)。
|
// 對齊 VISIONA_FRONTEND_URL(沿用 oidc-tdd.md §13.1 命名)。
|
||||||
PostLoginURL string
|
PostLoginURL string
|
||||||
|
|
||||||
// LogoutURL 是「讓使用者連帶登出 IdP(Member Center)session」的入口 URL。
|
|
||||||
//
|
|
||||||
// 背景(MC 限制,2026-06):MC 不支援標準 OIDC RP-initiated logout —
|
|
||||||
// discovery 雖列 end_session_endpoint=/auth/logout,但那是 POST+JSON 的 API
|
|
||||||
// (AuthController.Logout),非標準 GET + id_token_hint + post_logout_redirect_uri;
|
|
||||||
// 且 client 未註冊 post_logout_redirect_uri。因此後端無法乾淨地 302 導向 MC end_session。
|
|
||||||
//
|
|
||||||
// MC 端唯一「瀏覽器可觸發」的登出是 MemberCenter.**Web**(:7880)的 AccountController.Logout
|
|
||||||
// (路由 /account/logout,_signInManager.SignOutAsync() 清 MC session)。實測 GET 即回 302,
|
|
||||||
// 不必 form POST。**注意 host 必須是 MC Web :7880,不是 MC Api :7850**——:7850 沒 logout 頁、會 404。
|
|
||||||
// MC Web/Api 共享 DataProtection(SetApplicationName("MemberCenter")),清 :7880 session 後
|
|
||||||
// :7850 authorize 也視為未登入、下次登入會問帳密,可換帳號。
|
|
||||||
//
|
|
||||||
// 因此本欄位填 MC Web logout 入口(例:
|
|
||||||
// stage: https://stage-9527.innovedus.com:7880/account/logout)。
|
|
||||||
// 後端 logout handler 會把這個 URL 連同 method=GET 回給前端,由前端 navigate 觸發 MC 登出
|
|
||||||
// (見 oidc_auth.go oidcLogoutHandler)。
|
|
||||||
//
|
|
||||||
// ⚠️ stage MC 跑 master 舊版(2026-04-30 image)、logout 不支援 returnUrl,清完會停在 MC 首頁、
|
|
||||||
// 不自動回 visionA,前端需自行導回 visionA 登入。
|
|
||||||
//
|
|
||||||
// **設計成可關**:留空時 logout response 不回 idp_logout 欄位、維持「只清本地 session」
|
|
||||||
// 的舊行為(向下相容)。對齊 VISIONA_OIDC_LOGOUT_URL。
|
|
||||||
//
|
|
||||||
// TODO(MC 補標準 RP-initiated logout 後):改回後端 302 導向標準 end_session_endpoint,
|
|
||||||
// 移除這個權宜欄位。
|
|
||||||
LogoutURL string
|
|
||||||
|
|
||||||
// PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login`:
|
|
||||||
// - true → 每次登入都讓 IdP 強制重新認證(忽略既有 SSO session)。
|
|
||||||
// - false → 沿用 IdP 既有 session(標準 SSO),為預設值。
|
|
||||||
// stage 想「每次都重新輸入帳密」時設 true;prod 視 UX 決定。
|
|
||||||
// 對齊 VISIONA_OIDC_PROMPT_LOGIN(預設 false)。
|
|
||||||
PromptLogin bool
|
|
||||||
|
|
||||||
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id,
|
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id,
|
||||||
// 預留給未來 client_credentials grant flow(例如查詢使用者組織、推送通知等)。
|
// 預留給未來 client_credentials grant flow(例如查詢使用者組織、推送通知等)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -38,11 +38,6 @@ func Load() *Config {
|
|||||||
ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""),
|
ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""),
|
||||||
RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""),
|
RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""),
|
||||||
PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""),
|
PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""),
|
||||||
// LogoutURL:MC Web :7880 logout 入口(GET /account/logout)。留空=logout 只清本地、
|
|
||||||
// 不連動 MC(向下相容,見 OIDCConfig.LogoutURL 註解)。
|
|
||||||
LogoutURL: getEnvString("VISIONA_OIDC_LOGOUT_URL", ""),
|
|
||||||
// prompt=login:true 時每次登入都強制 IdP 重新認證(忽略既有 SSO session)。
|
|
||||||
PromptLogin: getEnvBool("VISIONA_OIDC_PROMPT_LOGIN", false),
|
|
||||||
// A1:client_credentials grant 預留欄位,留空表「不啟用 service client」。
|
// A1:client_credentials grant 預留欄位,留空表「不啟用 service client」。
|
||||||
ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""),
|
ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""),
|
||||||
ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""),
|
ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""),
|
||||||
|
|||||||
@ -165,23 +165,6 @@ type ConverterJob struct {
|
|||||||
ErrorCode string // 取自 error.code
|
ErrorCode string // 取自 error.code
|
||||||
ErrorMessage string // 取自 error.message
|
ErrorMessage string // 取自 error.message
|
||||||
TargetObjectKey string // 僅 promote 後才有;GET / list 時為 ""
|
TargetObjectKey string // 僅 promote 後才有;GET / list 時為 ""
|
||||||
|
|
||||||
// ── 模型 metadata(B4,optional / 防禦性)─────────────────────────────────
|
|
||||||
//
|
|
||||||
// 來源:轉檔服務端 bie worker 的 analysis_info(services/workers/bie/core.py)。
|
|
||||||
// 轉檔完成(status=completed)時,converter 端**預期**會把 analysis_info 串進
|
|
||||||
// `GET /api/v1/jobs/{id}` 的 response(見下方 converterJobJSON.AnalysisInfo 假設的 schema)。
|
|
||||||
//
|
|
||||||
// **這幾個欄位全部 optional**:轉檔端尚未串好 analysis_info 時(目前狀態),
|
|
||||||
// 這些欄位維持零值(nil / ""),不影響建 model(InputShape 留空即可)。
|
|
||||||
//
|
|
||||||
// 轉檔端 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。
|
// ConverterPromoteResult 是 Promote 的 response shape。
|
||||||
@ -692,7 +675,6 @@ func (c *converterClient) mapListJobsError(status int, body []byte) error {
|
|||||||
// - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同
|
// - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同
|
||||||
//
|
//
|
||||||
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
|
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
|
||||||
//
|
|
||||||
// — request body 可能在 retry 時已被讀完,必須重建。caller 內部用 bytes.NewReader 等可重建的 body。
|
// — request body 可能在 retry 時已被讀完,必須重建。caller 內部用 bytes.NewReader 等可重建的 body。
|
||||||
func (c *converterClient) doWithRetry(
|
func (c *converterClient) doWithRetry(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@ -912,7 +894,6 @@ func extractActiveJobFromDetails(raw json.RawMessage) *Job {
|
|||||||
// 為了同時支援:
|
// 為了同時支援:
|
||||||
// - CreateJobResponse(POST /jobs 201)— 無 stage_progress / input.filename 等欄位
|
// - CreateJobResponse(POST /jobs 201)— 無 stage_progress / input.filename 等欄位
|
||||||
// - Job(GET /jobs/{id})— 完整欄位
|
// - Job(GET /jobs/{id})— 完整欄位
|
||||||
//
|
|
||||||
// 全部欄位都用 pointer 或 nullable,Marshal 時靠下方 toConverterJob 統一轉。
|
// 全部欄位都用 pointer 或 nullable,Marshal 時靠下方 toConverterJob 統一轉。
|
||||||
type converterJobJSON struct {
|
type converterJobJSON struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
@ -934,67 +915,6 @@ type converterJobJSON struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
} `json:"error"`
|
} `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_info(input_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 用的 []int(NCHW)。
|
|
||||||
//
|
|
||||||
// 優先序:
|
|
||||||
// 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。
|
// parseConverterJob 解 GET /api/v1/jobs/{id} 或 POST /api/v1/jobs 201 的 response。
|
||||||
@ -1033,14 +953,6 @@ func (jr *converterJobJSON) toConverterJob() *ConverterJob {
|
|||||||
cj.ErrorCode = jr.Error.Code
|
cj.ErrorCode = jr.Error.Code
|
||||||
cj.ErrorMessage = jr.Error.Message
|
cj.ErrorMessage = jr.Error.Message
|
||||||
}
|
}
|
||||||
// B4 模型 metadata(optional / 防禦性):轉檔端沒串 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
|
return cj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1274,11 +1274,11 @@ func TestGetResult_EmptyJobID(t *testing.T) {
|
|||||||
// TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。
|
// TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。
|
||||||
//
|
//
|
||||||
// v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-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 解碼行為
|
// 對 charset-encoded `filename*` 參數的 transparent 解碼行為
|
||||||
// - Hostile-input:CRLF injection / path traversal / null byte / extreme length
|
// - Hostile-input:CRLF 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*`)。
|
// **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。
|
||||||
// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded
|
// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded
|
||||||
// (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。
|
// (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。
|
||||||
@ -1339,149 +1339,6 @@ func TestParseFilenameFromContentDisposition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// B4:analysis_info → ConverterJob metadata 對映(parse 層)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestParseConverterJob_AnalysisInfo_InputShape:GET /jobs/{id} response 帶 analysis_info
|
|
||||||
// 含明確 input_shape 陣列 → ConverterJob.InputShape / Classes / Framework 正確對映。
|
|
||||||
//
|
|
||||||
// 對齊 converterJobJSON.AnalysisInfo 假設 schema:input_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_DimsFallback:analysis_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_EmptyObject:analysis_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_NilReceiver:nil *converterAnalysisInfoJSON
|
|
||||||
// 呼叫 toInputShape 不 panic、回 nil(nil-safe 保證)。
|
|
||||||
func TestConverterAnalysisInfoJSON_ToInputShape_NilReceiver(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var a *converterAnalysisInfoJSON
|
|
||||||
assert.Nil(t, a.toInputShape(), "nil receiver 應回 nil、不 panic")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// 共用:interface 契約 + helpers
|
// 共用:interface 契約 + helpers
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@ -82,12 +82,6 @@ type ModelRecord struct {
|
|||||||
FileSize int64
|
FileSize int64
|
||||||
FileChecksum string
|
FileChecksum string
|
||||||
TargetChip string
|
TargetChip string
|
||||||
// ── 模型 metadata(B4,optional)─────────────────────────────────────────
|
|
||||||
// 來源: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"
|
Source string // 永遠 "converted"
|
||||||
SourceJobID string
|
SourceJobID string
|
||||||
// FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。
|
// FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。
|
||||||
@ -679,12 +673,6 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
|
|||||||
FileSize: promoteRes.Size,
|
FileSize: promoteRes.Size,
|
||||||
FileChecksum: promoteRes.Checksum,
|
FileChecksum: promoteRes.Checksum,
|
||||||
TargetChip: normalizeTargetChip(cj.Platform),
|
TargetChip: normalizeTargetChip(cj.Platform),
|
||||||
// B4 模型 metadata:從 converter job 的 analysis_info 接過來(cj 由上面 step 2
|
|
||||||
// 的 GetJob 取得)。轉檔端尚未串好 analysis_info 時 cj.InputShape 等為零值 →
|
|
||||||
// 這裡也維持零值,不影響建 model(InputShape 留空)。
|
|
||||||
InputShape: cj.InputShape,
|
|
||||||
Classes: cj.Classes,
|
|
||||||
Framework: cj.Framework,
|
|
||||||
Source: "converted",
|
Source: "converted",
|
||||||
SourceJobID: jobID,
|
SourceJobID: jobID,
|
||||||
FAAObjectKey: faaObjectKey,
|
FAAObjectKey: faaObjectKey,
|
||||||
@ -853,7 +841,6 @@ func defaultDownloadFilename(cj *ConverterJob) string {
|
|||||||
//
|
//
|
||||||
// 用 modelStore.FindBySourceJobID 當 source-of-truth:若已有 model record 表示
|
// 用 modelStore.FindBySourceJobID 當 source-of-truth:若已有 model record 表示
|
||||||
// PromoteToModels 已成功跑過,可直接從 record 拿 storage_key 反推 target_object_key?
|
// PromoteToModels 已成功跑過,可直接從 record 拿 storage_key 反推 target_object_key?
|
||||||
//
|
|
||||||
// ✗ 不行:storage_key 是 visionA storage 的 key,不是 FAA 的 object_key。
|
// ✗ 不行:storage_key 是 visionA storage 的 key,不是 FAA 的 object_key。
|
||||||
//
|
//
|
||||||
// 改用 converter.Promote 冪等性(§2.7:「promote 動作是冪等的,converter 端對同一
|
// 改用 converter.Promote 冪等性(§2.7:「promote 動作是冪等的,converter 端對同一
|
||||||
|
|||||||
@ -15,15 +15,10 @@
|
|||||||
//
|
//
|
||||||
// Phase 0.8 conversion (見 docs/autoflow/04-architecture/conversion.md §2.7)
|
// Phase 0.8 conversion (見 docs/autoflow/04-architecture/conversion.md §2.7)
|
||||||
// Phase 0.8b T4:DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken
|
// Phase 0.8b T4:DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken
|
||||||
//
|
|
||||||
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
|
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
|
||||||
//
|
|
||||||
// Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult
|
// Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult
|
||||||
//
|
|
||||||
// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6)
|
// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6)
|
||||||
//
|
|
||||||
// Phase 0.8b v0.6 T3:flowStubFAA + flowFixture.faa 欄位整段砍除(ADR-016 撤回 FAA 直連、
|
// Phase 0.8b v0.6 T3:flowStubFAA + flowFixture.faa 欄位整段砍除(ADR-016 撤回 FAA 直連、
|
||||||
//
|
|
||||||
// faa_client.go 整檔刪除);FlowOpts.FAA 必填校驗一併移除。
|
// faa_client.go 整檔刪除);FlowOpts.FAA 必填校驗一併移除。
|
||||||
package conversion
|
package conversion
|
||||||
|
|
||||||
@ -865,71 +860,6 @@ func TestPromoteToModels_HappyPath(t *testing.T) {
|
|||||||
// assertion 雙重防護(conversion_e2e_test.go:TestConversionE2E_DownloadStream)
|
// assertion 雙重防護(conversion_e2e_test.go:TestConversionE2E_DownloadStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPromoteToModels_Metadata_WrittenToRecord:B4 — converter job 帶 analysis_info
|
|
||||||
// 對映出的 InputShape / Classes / Framework,PromoteToModels 建 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 寫進 ModelRecord(NCHW)")
|
|
||||||
assert.Equal(t, []string{"face", "person"}, rec.Classes)
|
|
||||||
assert.Equal(t, "onnx", rec.Framework)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPromoteToModels_Metadata_Absent_StillSucceeds:B4 防禦性 — 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_DefaultName:caller 傳空 name 應走 fallback `<stem>_kl<chip>`。
|
// TestPromoteToModels_DefaultName:caller 傳空 name 應走 fallback `<stem>_kl<chip>`。
|
||||||
func TestPromoteToModels_DefaultName(t *testing.T) {
|
func TestPromoteToModels_DefaultName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@ -1,506 +0,0 @@
|
|||||||
//go:build realconv
|
|
||||||
|
|
||||||
// real_chain_e2e_test.go — 完整鏈路 e2e:真轉檔 → PromoteToModels → 真 model 進 PG 模型庫。
|
|
||||||
//
|
|
||||||
// Owner: testing agent(realconv 完整鏈路 e2e — build tag 隔離,預設 CI 不跑)
|
|
||||||
//
|
|
||||||
// 與 real_converter_e2e_test.go 的差異:
|
|
||||||
// - real_converter_e2e_test.go:對「真轉檔服務」驗 visionA ConverterClient 的 **contract**
|
|
||||||
// (連線/認證/InitJob/GetJob/GetResult 解析、promote 失敗路徑)。當時 stage 是 stub。
|
|
||||||
// - 本檔:stage 已切 **real 模式**(真 KTC、真 nef 800KB、promote OAuth 已修),驗
|
|
||||||
// **整條 visionA 業務鏈路到底**:
|
|
||||||
// InitJob(真送 onnx+56 圖)→ poll completed(真轉檔)
|
|
||||||
// → flow.PromoteToModels(promote→converter MinIO pull NEF→storage.Put→建 model record)
|
|
||||||
// → **model 真的進了 PG 模型庫**(用 model.PostgresRepository 查、驗 owner/source_job_id/storage_key/faa_object_key)
|
|
||||||
// → 冪等(同 jobID 再 promote 回既有 model、不重複建)
|
|
||||||
//
|
|
||||||
// ⚠️ v0.6 架構事實(flow.go PromoteToModels):promote 與 download 都走
|
|
||||||
//
|
|
||||||
// `converter.GetResult`(converter MinIO),visionA 端**不再直接打 FAA**。所以本鏈路
|
|
||||||
// **不需要** wire FAA / MC client —— 只需要:真 ConverterClient(:9501 + API key)
|
|
||||||
// + 真 PG(建 model record)+ 一個 storage(streaming 寫 NEF;用本機 tmpdir LocalFS)。
|
|
||||||
// 這是「能在本檔組出真 conversion service」的關鍵:依賴比想像中少。
|
|
||||||
//
|
|
||||||
// 三個必要外部資源(缺任一 → t.Skip 並印啟用指令):
|
|
||||||
// 1. VISIONA_REAL_CONVERTER_URL + VISIONA_CONVERTER_API_KEY(與 real_converter_e2e_test.go 共用)
|
|
||||||
// 2. VISIONA_REAL_PG_DSN(已 migrate 的 PG;可指 stage 真 PG 或本機 docker PG)
|
|
||||||
// 3. onnx + ref images fixture(與 real_converter_e2e_test.go 共用 default 路徑 / env 覆寫)
|
|
||||||
//
|
|
||||||
// 本機通常連不到 stage(VPN/網路)+ 無 PG → 預設 Skip。由 Orchestrator 對 stage 跑
|
|
||||||
// (見檔尾「給 Orchestrator 對 stage 跑」段)。
|
|
||||||
package conversion
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"visiona-backend/internal/model"
|
|
||||||
"visiona-backend/internal/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 環境 guard(PG DSN 是本檔額外需要的;converter / fixture 沿用 requireRealConvEnv)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
const (
|
|
||||||
// realChainPGDSNEnv 是已 migrate 的 PostgreSQL DSN(如
|
|
||||||
// postgres://visiona:pw@192.168.0.130:5432/visiona?sslmode=disable)。
|
|
||||||
// 缺 → 本檔的完整鏈路測試 Skip(contract 測試在 real_converter_e2e_test.go 仍可獨立跑)。
|
|
||||||
realChainPGDSNEnv = "VISIONA_REAL_PG_DSN"
|
|
||||||
|
|
||||||
// 真 KTC 轉檔比 stub 慢得多(onnx→bie→nef 三 stage、bie 量化要跑 56 張 ref 圖)。
|
|
||||||
// 給足 deadline;stub 經驗幾秒,real 端視機器可能數分鐘。
|
|
||||||
realChainPollTimeout = 10 * time.Minute
|
|
||||||
realChainPollInterval = 3 * time.Second
|
|
||||||
|
|
||||||
// 測試建立的 model name 前綴(可識別、cleanup 用)。
|
|
||||||
realChainModelNamePrefix = "e2e-realchain-"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requireRealChainPG 解析 PG DSN、建 pool;缺 env → Skip。pool 在 t.Cleanup 關閉。
|
|
||||||
func requireRealChainPG(t *testing.T) *pgxpool.Pool {
|
|
||||||
t.Helper()
|
|
||||||
dsn := os.Getenv(realChainPGDSNEnv)
|
|
||||||
if dsn == "" {
|
|
||||||
t.Skipf(`real-chain e2e 跳過:未設 %s(PG DSN)。
|
|
||||||
本測試需要一個「已 migrate(含 users / models 表)」的 PostgreSQL:
|
|
||||||
- 指 stage 真 PG,或本機 docker PG(schema 來自 migrations/0001_create_users_models.up.sql)
|
|
||||||
範例:
|
|
||||||
VISIONA_REAL_PG_DSN="postgres://visiona:<pw>@192.168.0.130:5432/visiona?sslmode=disable"`, realChainPGDSNEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("real-chain e2e 跳過:PG pool 建立失敗(DSN 可達性問題?):%v", err)
|
|
||||||
}
|
|
||||||
// ping 確認連得上 + schema 存在(查 models 表)
|
|
||||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer pingCancel()
|
|
||||||
if err := pool.Ping(pingCtx); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
t.Skipf("real-chain e2e 跳過:PG ping 失敗:%v", err)
|
|
||||||
}
|
|
||||||
var reg int
|
|
||||||
if err := pool.QueryRow(pingCtx, `SELECT 1 FROM information_schema.tables
|
|
||||||
WHERE table_name = 'models'`).Scan(®); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
t.Skipf("real-chain e2e 跳過:PG 缺 models 表(DSN 指到未 migrate 的 DB?):%v。"+
|
|
||||||
"請先對該 DB 跑 migrations/0001_create_users_models.up.sql。", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() { pool.Close() })
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureTestUser upsert 一個合法 UUID user(滿足 models.owner_user_id FK)。
|
|
||||||
//
|
|
||||||
// 回傳該 user 的 UUID 字串。固定 UUID(deterministic)讓重跑時 idempotent;
|
|
||||||
// email 帶可識別前綴避免撞真 user。
|
|
||||||
func ensureTestUser(t *testing.T, pool *pgxpool.Pool) string {
|
|
||||||
t.Helper()
|
|
||||||
// 固定 namespace UUID(v5 不需要;這裡直接寫死一個明顯是測試用的 UUID)。
|
|
||||||
const testUserID = "e2e0c0de-0000-4000-8000-000000000001"
|
|
||||||
const testEmail = "e2e-realchain@example.invalid"
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// upsert by id;email 用 functional unique index(lower(email)),ON CONFLICT (id) 即可。
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO users (id, email, name, roles)
|
|
||||||
VALUES ($1, $2, 'e2e realchain test user', '{}')
|
|
||||||
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, updated_at = now()`,
|
|
||||||
testUserID, testEmail)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ensure test user(upsert users)失敗:%v\n"+
|
|
||||||
"(若是 email unique 衝突,代表有殘留同 email 的別的 user — 換 testEmail 或先清)", err)
|
|
||||||
}
|
|
||||||
return testUserID
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// in-test adapters:把 model.PostgresRepository / storage.LocalFSStore 包成
|
|
||||||
// conversion.ModelStore / conversion.Storage(對映 cmd/api-server/conversion_adapters.go,
|
|
||||||
// 但 main package 的 adapter 不可 import,這裡在 conversion package 內等價重寫)。
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// pgModelStore 把 model.Repository 包成 conversion.ModelStore(含 ModelRecord ↔ model.Model 轉換)。
|
|
||||||
type pgModelStore struct {
|
|
||||||
repo model.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *pgModelStore) Save(ctx context.Context, rec *ModelRecord) error {
|
|
||||||
if rec == nil {
|
|
||||||
return errors.New("pgModelStore.Save requires non-nil record")
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
uploadedAt := now
|
|
||||||
if !rec.UpdatedAt.IsZero() {
|
|
||||||
uploadedAt = rec.UpdatedAt
|
|
||||||
}
|
|
||||||
m := &model.Model{
|
|
||||||
ID: rec.ID,
|
|
||||||
OwnerUserID: rec.OwnerUserID,
|
|
||||||
Name: rec.Name,
|
|
||||||
Description: rec.Description,
|
|
||||||
StorageKey: rec.StorageKey,
|
|
||||||
FileSize: rec.FileSize,
|
|
||||||
FileChecksum: rec.FileChecksum,
|
|
||||||
TargetChip: rec.TargetChip,
|
|
||||||
InputShape: rec.InputShape,
|
|
||||||
Classes: rec.Classes,
|
|
||||||
Framework: rec.Framework,
|
|
||||||
Source: rec.Source,
|
|
||||||
SourceJobID: rec.SourceJobID,
|
|
||||||
FAAObjectKey: rec.FAAObjectKey,
|
|
||||||
CreatedAt: rec.CreatedAt,
|
|
||||||
UpdatedAt: rec.UpdatedAt,
|
|
||||||
UploadedAt: &uploadedAt,
|
|
||||||
}
|
|
||||||
return s.repo.Save(ctx, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *pgModelStore) FindBySourceJobID(ctx context.Context, ownerUserID, sourceJobID string) (*ModelRecord, error) {
|
|
||||||
if ownerUserID == "" || sourceJobID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
models, err := s.repo.List(ctx, model.ListFilter{
|
|
||||||
OwnerUserID: ownerUserID,
|
|
||||||
Source: model.SourceConverted,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("pgModelStore.FindBySourceJobID list: %w", err)
|
|
||||||
}
|
|
||||||
for _, m := range models {
|
|
||||||
if m.SourceJobID == sourceJobID {
|
|
||||||
return &ModelRecord{
|
|
||||||
ID: m.ID,
|
|
||||||
OwnerUserID: m.OwnerUserID,
|
|
||||||
Name: m.Name,
|
|
||||||
Description: m.Description,
|
|
||||||
StorageKey: m.StorageKey,
|
|
||||||
FileSize: m.FileSize,
|
|
||||||
FileChecksum: m.FileChecksum,
|
|
||||||
TargetChip: m.TargetChip,
|
|
||||||
InputShape: m.InputShape,
|
|
||||||
Classes: m.Classes,
|
|
||||||
Framework: m.Framework,
|
|
||||||
Source: m.Source,
|
|
||||||
SourceJobID: m.SourceJobID,
|
|
||||||
FAAObjectKey: m.FAAObjectKey,
|
|
||||||
CreatedAt: m.CreatedAt,
|
|
||||||
UpdatedAt: m.UpdatedAt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *pgModelStore) GenerateID() string { return uuid.NewString() }
|
|
||||||
|
|
||||||
// localStorage 把 storage.Store 包成 conversion.Storage(只需 Put)。
|
|
||||||
type localStorage struct {
|
|
||||||
store storage.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *localStorage) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error {
|
|
||||||
return s.store.Put(ctx, key, r, size, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildRealChainService 組一個「真 converter + 真 PG + 本機 LocalFS storage」的 conversion.Service。
|
|
||||||
//
|
|
||||||
// 回傳 service + 底層 pgRepo(測試直接用 repo 查 PG 驗證 model 落盤)。
|
|
||||||
func buildRealChainService(t *testing.T, env realConvEnv, pool *pgxpool.Pool) (Service, *model.PostgresRepository) {
|
|
||||||
t.Helper()
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
|
||||||
|
|
||||||
converterClient := NewConverterClient(ConverterClientOpts{
|
|
||||||
BaseURL: env.baseURL,
|
|
||||||
APIKey: env.apiKey,
|
|
||||||
Logger: logger,
|
|
||||||
})
|
|
||||||
ownership := NewOwnership(converterClient, logger)
|
|
||||||
|
|
||||||
pgRepo := model.NewPostgresRepository(pool)
|
|
||||||
modelStore := &pgModelStore{repo: pgRepo}
|
|
||||||
|
|
||||||
// 本機 tmpdir LocalFS storage(NEF streaming 寫進去;測完隨 t.TempDir 清)。
|
|
||||||
fsStore, err := storage.NewLocalFSStore(t.TempDir(), "http://localhost/files", "e2e-realchain-signing")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("建 LocalFSStore 失敗:%v", err)
|
|
||||||
}
|
|
||||||
storageAdapter := &localStorage{store: fsStore}
|
|
||||||
|
|
||||||
svc, err := NewService(FlowOpts{
|
|
||||||
Converter: converterClient,
|
|
||||||
Ownership: ownership,
|
|
||||||
ModelStore: modelStore,
|
|
||||||
Storage: storageAdapter,
|
|
||||||
Logger: logger,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewService 失敗:%v", err)
|
|
||||||
}
|
|
||||||
return svc, pgRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// E2E:完整鏈路 — 真轉檔 → PromoteToModels → 真 model 進 PG → 冪等
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestRealChain_ConvertPromoteToPGModelLibrary 驗整條 visionA 業務鏈路到底。
|
|
||||||
//
|
|
||||||
// [1] flow.InitJob 真送 onnx + 56 圖(flow 內部重組 multipart、注入 user_id、寫 ownership)
|
|
||||||
// [2] flow.GetJob poll 到 completed(真 KTC 轉檔;給足 10 分鐘)
|
|
||||||
// [3] flow.PromoteToModels:promote(真 nef 推上 FAA + 保留 converter MinIO)
|
|
||||||
// → converter.GetResult 拉 NEF stream → storage.Put → model.PostgresRepository.Save
|
|
||||||
// [4] 驗 model 真的進 PG:用 pgRepo.Get(model_id) + List by owner,確認
|
|
||||||
// owner / source_job_id / storage_key / file_size / source=converted / faa_object_key 有值
|
|
||||||
// [5] 冪等:同 jobID 再 PromoteToModels → 回**同一個** model_id(不重複建)
|
|
||||||
func TestRealChain_ConvertPromoteToPGModelLibrary(t *testing.T) {
|
|
||||||
env := requireRealConvEnv(t)
|
|
||||||
pool := requireRealChainPG(t)
|
|
||||||
userID := ensureTestUser(t, pool)
|
|
||||||
svc, pgRepo := buildRealChainService(t, env, pool)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// ── [1] flow.InitJob(真送)─────────────────────────────────────────────
|
|
||||||
body, contentType, refCount := buildRealInitBody(t, env)
|
|
||||||
t.Logf("[1] InitJob:multipart %d bytes,ref_images=%d 張,user=%s", len(body), refCount, userID)
|
|
||||||
|
|
||||||
// 注意:flow.InitJob 內部會重組 multipart 並注入 user_id(黑名單 client 帶來的 user_id)。
|
|
||||||
// buildRealInitBody 已寫了 user_id=realConvTestUserID,但會被 flow 用本測 userID 蓋掉,
|
|
||||||
// 這正是要驗的安全行為(§4.2)。傳進去的 ContentType 必須含 boundary。
|
|
||||||
initCtx, initCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
||||||
job, err := svc.InitJob(initCtx, InitJobInput{
|
|
||||||
UserID: userID,
|
|
||||||
ContentType: contentType,
|
|
||||||
Body: bytes.NewReader(body),
|
|
||||||
ContentLength: int64(len(body)),
|
|
||||||
})
|
|
||||||
initCancel()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, ErrConverterAuthFailed) {
|
|
||||||
t.Fatalf("[1] InitJob 認證失敗(API key 未對齊?):%v", err)
|
|
||||||
}
|
|
||||||
t.Fatalf("[1] InitJob 失敗:%v", err)
|
|
||||||
}
|
|
||||||
if job.JobID == "" {
|
|
||||||
t.Fatalf("[1] InitJob 回的 job_id 為空:%+v", job)
|
|
||||||
}
|
|
||||||
jobID := job.JobID
|
|
||||||
t.Logf("[1] InitJob OK:job_id=%s status=%s stage=%s", jobID, job.Status, job.Stage)
|
|
||||||
|
|
||||||
// ── [2] flow.GetJob poll 到 completed(真轉檔,給足 10 分鐘)──────────────
|
|
||||||
final := pollChainUntilTerminal(t, svc, userID, jobID)
|
|
||||||
t.Logf("[2] 終態:status=%s stage=%q source_filename=%q target_chip=%q error_code=%q",
|
|
||||||
final.Status, final.Stage, final.SourceFilename, final.TargetChip, final.ErrorCode)
|
|
||||||
if final.Status != "completed" {
|
|
||||||
t.Fatalf("[2] 真轉檔未 completed(status=%s error_code=%q msg=%q)。"+
|
|
||||||
"確認 stage worker 已切 real 模式、且該 fixture 能轉成功。",
|
|
||||||
final.Status, final.ErrorCode, final.ErrorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── [3] flow.PromoteToModels(真 promote → MinIO pull → storage → 建 model record)──
|
|
||||||
modelName := realChainModelNamePrefix + jobID[:8]
|
|
||||||
promoteCtx, promoteCancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
||||||
promoteRes, err := svc.PromoteToModels(promoteCtx, userID, jobID, modelName)
|
|
||||||
promoteCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[3] PromoteToModels 失敗:%v\n"+
|
|
||||||
"(promote OAuth 已修的前提下不該失敗;若回 ErrConverterUnavailable 代表 promote→FAA 仍有問題,"+
|
|
||||||
"回報 Orchestrator,勿自行改 production code。)", err)
|
|
||||||
}
|
|
||||||
if promoteRes == nil || promoteRes.ModelID == "" {
|
|
||||||
t.Fatalf("[3] PromoteToModels 回的 model_id 為空:%+v", promoteRes)
|
|
||||||
}
|
|
||||||
modelID := promoteRes.ModelID
|
|
||||||
// cleanup:測完軟刪除 model(pgRepo.Delete 寫 deleted_at;不留垃圾在模型庫)。
|
|
||||||
t.Cleanup(func() {
|
|
||||||
dctx, dcancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer dcancel()
|
|
||||||
if derr := pgRepo.Delete(dctx, modelID); derr != nil {
|
|
||||||
t.Logf("cleanup:軟刪除 model %s 失敗(殘留在 PG,需人工清):%v", modelID, derr)
|
|
||||||
} else {
|
|
||||||
t.Logf("cleanup:已軟刪除 model %s", modelID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Logf("[3] PromoteToModels OK:model_id=%s name=%q source=%s source_job_id=%s file_size=%d status=%s",
|
|
||||||
modelID, promoteRes.Name, promoteRes.Source, promoteRes.SourceJobID, promoteRes.FileSize, promoteRes.Status)
|
|
||||||
|
|
||||||
// 基本一致性
|
|
||||||
if promoteRes.Source != "converted" {
|
|
||||||
t.Errorf("[3] promote source 預期 converted,得 %q", promoteRes.Source)
|
|
||||||
}
|
|
||||||
if promoteRes.SourceJobID != jobID {
|
|
||||||
t.Errorf("[3] promote source_job_id 預期 %s,得 %s", jobID, promoteRes.SourceJobID)
|
|
||||||
}
|
|
||||||
if promoteRes.FileSize <= 0 {
|
|
||||||
t.Errorf("[3] promote file_size 應 > 0(真 nef ~800KB),得 %d", promoteRes.FileSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── [4] 驗 model 真的進 PG(直接查 PostgresRepository)────────────────────
|
|
||||||
getCtx, getCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
got, err := pgRepo.Get(getCtx, modelID)
|
|
||||||
getCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[4] 從 PG 查不到 model %s(model 未真正落盤?):%v", modelID, err)
|
|
||||||
}
|
|
||||||
t.Logf("[4] PG model 落盤:id=%s owner=%s name=%q storage_key=%q faa_object_key=%q "+
|
|
||||||
"file_size=%d source=%s source_job_id=%s target_chip=%q",
|
|
||||||
got.ID, got.OwnerUserID, got.Name, got.StorageKey, got.FAAObjectKey,
|
|
||||||
got.FileSize, got.Source, got.SourceJobID, got.TargetChip)
|
|
||||||
|
|
||||||
if got.OwnerUserID != userID {
|
|
||||||
t.Errorf("[4] PG model owner 預期 %s,得 %s", userID, got.OwnerUserID)
|
|
||||||
}
|
|
||||||
if got.SourceJobID != jobID {
|
|
||||||
t.Errorf("[4] PG model source_job_id 預期 %s,得 %s", jobID, got.SourceJobID)
|
|
||||||
}
|
|
||||||
if got.Source != model.SourceConverted {
|
|
||||||
t.Errorf("[4] PG model source 預期 converted,得 %q", got.Source)
|
|
||||||
}
|
|
||||||
if got.StorageKey == "" {
|
|
||||||
t.Errorf("[4] PG model storage_key 不該為空(promote 應寫 visionA storage key)")
|
|
||||||
}
|
|
||||||
if got.FileSize <= 0 {
|
|
||||||
t.Errorf("[4] PG model file_size 應 > 0,得 %d", got.FileSize)
|
|
||||||
}
|
|
||||||
// FAAObjectKey:v0.6 promote 仍寫此欄位(= converter promote 的 target_object_key)。
|
|
||||||
// 真 promote 成功路徑下應有值;若為空記下供判讀(不一定 fail — 視 converter promote response)。
|
|
||||||
if got.FAAObjectKey == "" {
|
|
||||||
t.Logf("[4] 注意:PG model faa_object_key 為空。v0.6 promote 應回 target_object_key;" +
|
|
||||||
"若 converter promote response 未帶 target_object_key 則 fallback 為 visionA 端組的 key," +
|
|
||||||
"理論上不該空。確認 converter promote 回傳格式。")
|
|
||||||
}
|
|
||||||
|
|
||||||
// List by owner 也應看得到(驗 List 路徑 + filter 正確)
|
|
||||||
listCtx, listCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
models, err := pgRepo.List(listCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted})
|
|
||||||
listCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[4] List by owner 失敗:%v", err)
|
|
||||||
}
|
|
||||||
if !containsModelID(models, modelID) {
|
|
||||||
t.Errorf("[4] List by owner=%s source=converted 結果未含 model %s(共 %d 筆)",
|
|
||||||
userID, modelID, len(models))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── [5] 冪等:同 jobID 再 PromoteToModels → 回同一個 model_id ──────────────
|
|
||||||
idemCtx, idemCancel := context.WithTimeout(ctx, 1*time.Minute)
|
|
||||||
promoteRes2, err := svc.PromoteToModels(idemCtx, userID, jobID, modelName)
|
|
||||||
idemCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[5] 第二次 PromoteToModels(冪等)失敗:%v", err)
|
|
||||||
}
|
|
||||||
if promoteRes2 == nil || promoteRes2.ModelID != modelID {
|
|
||||||
t.Errorf("[5] 冪等失敗:第二次 promote 應回同一 model_id=%s,得 %+v", modelID, promoteRes2)
|
|
||||||
} else {
|
|
||||||
t.Logf("[5] 冪等 OK:同 jobID 再 promote 回既有 model_id=%s(未重複建)", modelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 再查一次 PG 確認只有一筆 converted model 對應此 jobID(冪等不該建第二筆)
|
|
||||||
cntCtx, cntCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
all, err := pgRepo.List(cntCtx, model.ListFilter{OwnerUserID: userID, Source: model.SourceConverted})
|
|
||||||
cntCancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[5] 冪等後 List 失敗:%v", err)
|
|
||||||
}
|
|
||||||
n := 0
|
|
||||||
for _, m := range all {
|
|
||||||
if m.SourceJobID == jobID {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n != 1 {
|
|
||||||
t.Errorf("[5] 冪等後對應 jobID=%s 的 converted model 應只有 1 筆,得 %d 筆", jobID, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("完整鏈路驗證通過:真轉檔 → PromoteToModels → model 進 PG → 冪等,全部 OK。")
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollChainUntilTerminal 用 flow.GetJob 對真服務 poll 到 completed/failed 或 timeout。
|
|
||||||
//
|
|
||||||
// 與 real_converter_e2e_test.go 的 pollUntilTerminal 不同:這裡走 **flow.GetJob**(含 ownership
|
|
||||||
// 檢查),驗的是 visionA 業務層的 poll,而非 raw client。回傳對外 *Job。
|
|
||||||
func pollChainUntilTerminal(t *testing.T, svc Service, userID, jobID string) *Job {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(realChainPollTimeout)
|
|
||||||
var last *Job
|
|
||||||
logEvery := 10 // 每 N 次 poll 印一次進度,避免長轉檔時 log 太吵
|
|
||||||
i := 0
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
j, err := svc.GetJob(ctx, userID, jobID)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("GetJob 暫時失敗(續 poll):%v", err)
|
|
||||||
time.Sleep(realChainPollInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
last = j
|
|
||||||
i++
|
|
||||||
if i%logEvery == 0 {
|
|
||||||
t.Logf(" poll #%d:status=%s stage=%q progress=%d stage_progress=%d",
|
|
||||||
i, j.Status, j.Stage, j.Progress, j.StageProgress)
|
|
||||||
}
|
|
||||||
switch j.Status {
|
|
||||||
case "completed", "failed":
|
|
||||||
return j
|
|
||||||
default:
|
|
||||||
time.Sleep(realChainPollInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if last == nil {
|
|
||||||
t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID)
|
|
||||||
}
|
|
||||||
t.Logf("poll 超時(%s),回最後一次狀態:status=%s", realChainPollTimeout, last.Status)
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsModelID 檢查 model 清單是否含指定 id。
|
|
||||||
func containsModelID(models []*model.Model, id string) bool {
|
|
||||||
for _, m := range models {
|
|
||||||
if m.ID == id {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 給 Orchestrator 對 stage 跑(本機連不到 stage / 無 PG → 預設 Skip)
|
|
||||||
//
|
|
||||||
// 前置:
|
|
||||||
// 1. 取 converter API key(從 stage container env,不 hardcode):
|
|
||||||
// KEY=$(docker -H tcp://192.168.0.130:2375 exec \
|
|
||||||
// kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY)
|
|
||||||
// 2. 取 PG DSN:指 stage 真 PG(已 migrate,含 users / models 表)。
|
|
||||||
// 若 stage PG 連線資訊未知,可在 stage 機器上跑、用 localhost DSN;
|
|
||||||
// 或本機起一個 docker PG 並對它跑 migrations/0001_create_users_models.up.sql。
|
|
||||||
// 3. fixture:onnx + 56 張 ref 圖(default 路徑見 real_converter_e2e_test.go;
|
|
||||||
// env VISIONA_REAL_CONVERTER_ONNX / VISIONA_REAL_CONVERTER_IMAGES 可覆寫)。
|
|
||||||
//
|
|
||||||
// 跑:
|
|
||||||
// VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \
|
|
||||||
// VISIONA_CONVERTER_API_KEY="$KEY" \
|
|
||||||
// VISIONA_REAL_PG_DSN="postgres://visiona:<pw>@<pg-host>:5432/visiona?sslmode=disable" \
|
|
||||||
// go test -tags=realconv ./internal/conversion/ \
|
|
||||||
// -run TestRealChain_ConvertPromoteToPGModelLibrary -count=1 -v -timeout=20m
|
|
||||||
//
|
|
||||||
// 注意:真 KTC 轉檔較慢,-timeout 給 20m(poll deadline 內建 10m)。
|
|
||||||
// 測完會軟刪除建立的 model(t.Cleanup);test user 留在 PG(無害、固定 UUID 可重用)。
|
|
||||||
// ==========================================================================
|
|
||||||
@ -1,491 +0,0 @@
|
|||||||
//go:build realconv
|
|
||||||
|
|
||||||
// real_converter_e2e_test.go — 對「真實轉檔服務」(kneron_model_converter task-scheduler)
|
|
||||||
// 驗 visionA conversion client 的 contract e2e(**非 mock**)。
|
|
||||||
//
|
|
||||||
// Owner: testing agent(真轉檔 contract e2e — build tag 隔離,預設 CI 不跑)
|
|
||||||
//
|
|
||||||
// build tag `realconv`:只在 `go test -tags=realconv` 時編譯/執行。
|
|
||||||
// - 預設 `go test ./...` 與 `-tags=dbtest` 都**不**編譯本檔(避免外部服務依賴污染主測試集)。
|
|
||||||
// - 本機通常連不到 stage(VPN/網路),由 Orchestrator 對 stage 192.168.0.130 跑(見檔尾「給
|
|
||||||
// Orchestrator 對 stage 跑」段)。
|
|
||||||
//
|
|
||||||
// 測什麼(mock 測不到的部分):直接用 visionA 的 ConverterClient(converter_client.go,非
|
|
||||||
// httptest mock)打 stage 真轉檔服務,驗 visionA 端對「真服務實際回的格式」解析正確:
|
|
||||||
// 1. 連線 + 認證(真 API key 過認證)
|
|
||||||
// 2. InitJob 真送 onnx + 56 張 ref 圖(multipart)→ 拿 job_id / status=created / stage=onnx
|
|
||||||
// 3. GetJob poll 到 completed(stub 模式幾秒)→ 解析真服務 completed response 不報錯/不 panic
|
|
||||||
// 4. stub 環境真實行為:result_object_keys=null、analysis_info 不存在時,visionA
|
|
||||||
// InputShape/Classes/Framework 為零值(B4 鏈路防禦性,不報錯)
|
|
||||||
// 5. promote 失敗路徑:stub 無真 nef → converter 回 500 → visionA 正確包裝成 sentinel、不 panic
|
|
||||||
//
|
|
||||||
// ⚠️ 範圍界定:**對 stub 模式驗 contract**。stage 三個 worker(onnx/bie/nef)皆 WORKER_MODE=stub
|
|
||||||
// (Dockerfile.stub),不跑真 KTC、產佔位輸出(GET /result 回 15 bytes "STUB_NEF_OUTPUT")。
|
|
||||||
// 真 KTC 轉檔 / 真 nef / promote 成功路徑需轉檔端 worker 切真模式,**不在此測範圍**
|
|
||||||
// (那是 kneron_model_converter repo 的事,本測不嘗試開真 KTC / 改 worker 模式)。
|
|
||||||
//
|
|
||||||
// 對齊:converter_client.go endpoint 註解 + Orchestrator 手動實測的真服務 contract。
|
|
||||||
package conversion
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"mime/multipart"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// 環境 guard — 沒設 env / fixture 不存在 → t.Skip(優雅跳過,印清楚啟用方式)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
const (
|
|
||||||
// realConvURLEnv 是 stage 真轉檔服務 base URL(如 http://192.168.0.130:9501)。
|
|
||||||
realConvURLEnv = "VISIONA_REAL_CONVERTER_URL"
|
|
||||||
// realConvAPIKeyEnv 是 stage 上對齊的 converter API key(從 container env 取,**不 hardcode**)。
|
|
||||||
realConvAPIKeyEnv = "VISIONA_CONVERTER_API_KEY"
|
|
||||||
// realConvOnnxEnv 是 input onnx fixture 路徑(覆寫預設)。
|
|
||||||
realConvOnnxEnv = "VISIONA_REAL_CONVERTER_ONNX"
|
|
||||||
// realConvImagesEnv 是 ref images 目錄路徑(覆寫預設)。
|
|
||||||
realConvImagesEnv = "VISIONA_REAL_CONVERTER_IMAGES"
|
|
||||||
|
|
||||||
// 預設 fixture 路徑(Orchestrator 手動實測來源)。env 沒設時試這些,仍找不到 → Skip。
|
|
||||||
defaultOnnxFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie/input.onnx"
|
|
||||||
defaultImagesFixture = "/Users/jimchen/kneron_model_converter/tests/fixtures/bie_images"
|
|
||||||
|
|
||||||
// 測試用可識別 user_id(stub job 無害、會自然過期 expires_at)。
|
|
||||||
realConvTestUserID = "e2e-realconv-test"
|
|
||||||
// stub 模式下 onnx→bie→nef 幾秒就跑完;給寬裕 poll deadline。
|
|
||||||
realConvPollTimeout = 90 * time.Second
|
|
||||||
realConvPollInterval = 1 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// realConvEnv 是 guard 通過後回傳的環境設定。
|
|
||||||
type realConvEnv struct {
|
|
||||||
baseURL string
|
|
||||||
apiKey string
|
|
||||||
onnxPath string
|
|
||||||
imagesPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireRealConvEnv 檢查 env + fixture;缺任一 → t.Skip 並印「怎麼啟用這個測試」。
|
|
||||||
func requireRealConvEnv(t *testing.T) realConvEnv {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
baseURL := os.Getenv(realConvURLEnv)
|
|
||||||
apiKey := os.Getenv(realConvAPIKeyEnv)
|
|
||||||
|
|
||||||
if baseURL == "" || apiKey == "" {
|
|
||||||
t.Skipf(`real-converter e2e 跳過:未設環境變數。
|
|
||||||
啟用方式(對 stage 192.168.0.130 跑):
|
|
||||||
1. 取 stage 上對齊的 API key(從 converter container env):
|
|
||||||
KEY=$(docker -H tcp://192.168.0.130:2375 exec kneron_model_converter-scheduler-1 printenv CONVERTER_API_KEY)
|
|
||||||
2. 跑:
|
|
||||||
VISIONA_REAL_CONVERTER_URL=http://192.168.0.130:9501 \
|
|
||||||
VISIONA_CONVERTER_API_KEY="$KEY" \
|
|
||||||
go test -tags=realconv ./internal/conversion/ -run TestRealConverter -count=1 -v
|
|
||||||
缺少的環境變數:%s / %s(兩者皆必填)`, realConvURLEnv, realConvAPIKeyEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
onnxPath := os.Getenv(realConvOnnxEnv)
|
|
||||||
if onnxPath == "" {
|
|
||||||
onnxPath = defaultOnnxFixture
|
|
||||||
}
|
|
||||||
imagesPath := os.Getenv(realConvImagesEnv)
|
|
||||||
if imagesPath == "" {
|
|
||||||
imagesPath = defaultImagesFixture
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(onnxPath); err != nil {
|
|
||||||
t.Skipf("real-converter e2e 跳過:input onnx fixture 不存在:%s(err=%v)。"+
|
|
||||||
"用 %s 指定路徑。", onnxPath, err, realConvOnnxEnv)
|
|
||||||
}
|
|
||||||
if fi, err := os.Stat(imagesPath); err != nil || !fi.IsDir() {
|
|
||||||
t.Skipf("real-converter e2e 跳過:ref images 目錄不存在:%s(err=%v)。"+
|
|
||||||
"用 %s 指定路徑。", imagesPath, err, realConvImagesEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
return realConvEnv{
|
|
||||||
baseURL: baseURL,
|
|
||||||
apiKey: apiKey,
|
|
||||||
onnxPath: onnxPath,
|
|
||||||
imagesPath: imagesPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRealConverterClient 用真 env 建一個 ConverterClient(**非 mock**,打真服務)。
|
|
||||||
//
|
|
||||||
// 用較長的 init/get timeout:真服務 multipart 上傳 + stub 幾秒處理需要餘裕。
|
|
||||||
func newRealConverterClient(t *testing.T, env realConvEnv) ConverterClient {
|
|
||||||
t.Helper()
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
|
||||||
return NewConverterClient(ConverterClientOpts{
|
|
||||||
BaseURL: env.baseURL,
|
|
||||||
APIKey: env.apiKey,
|
|
||||||
Logger: logger,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// fixture:組真 multipart body(onnx + 56 張 ref 圖 + form fields)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// buildRealInitBody 組 stage 真轉檔服務 POST /api/v1/jobs 需要的 multipart body。
|
|
||||||
//
|
|
||||||
// 對齊 Orchestrator 手動實測的真 API contract:
|
|
||||||
// fields: model(file onnx) + ref_images(N files) + user_id + model_id + version + platform("520") + enable_evaluate("false")
|
|
||||||
//
|
|
||||||
// 回傳 body bytes + Content-Type(含 boundary)+ ref_images 數量(給斷言驗 ref_images_count)。
|
|
||||||
func buildRealInitBody(t *testing.T, env realConvEnv) (body []byte, contentType string, refCount int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
mw := multipart.NewWriter(&buf)
|
|
||||||
|
|
||||||
// form fields(converter multer 慣例:fields 在 file 之前)
|
|
||||||
writeField := func(name, val string) {
|
|
||||||
if err := mw.WriteField(name, val); err != nil {
|
|
||||||
t.Fatalf("write multipart field %q: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeField("user_id", realConvTestUserID)
|
|
||||||
// model_id 必須能 parse 成數字(轉檔服務驗證:非數字字串 → 400 validation_error)。
|
|
||||||
// 對齊手動實測(model_id=9999)與轉檔端 test_flow_e2e.py(model_id=10)。
|
|
||||||
writeField("model_id", "9999")
|
|
||||||
// version 為字串、無型別限制;用簡單值對齊手動成功("v1")。
|
|
||||||
writeField("version", "v1")
|
|
||||||
writeField("platform", "520")
|
|
||||||
writeField("enable_evaluate", "false")
|
|
||||||
|
|
||||||
// model file(onnx)
|
|
||||||
onnxBytes, err := os.ReadFile(env.onnxPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read onnx fixture %s: %v", env.onnxPath, err)
|
|
||||||
}
|
|
||||||
fw, err := mw.CreateFormFile("model", filepath.Base(env.onnxPath))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create form file model: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(onnxBytes); err != nil {
|
|
||||||
t.Fatalf("write onnx bytes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref_images:讀目錄下所有 *.jpg,依檔名數字排序送(與手動實測一致)
|
|
||||||
entries, err := os.ReadDir(env.imagesPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read images dir %s: %v", env.imagesPath, err)
|
|
||||||
}
|
|
||||||
jpgs := make([]string, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := e.Name()
|
|
||||||
if filepath.Ext(name) == ".jpg" {
|
|
||||||
jpgs = append(jpgs, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 依檔名數字排序(0.jpg, 1.jpg, ... 而非字典序 1,10,100)
|
|
||||||
sort.Slice(jpgs, func(i, j int) bool {
|
|
||||||
ni := jpgStem(jpgs[i])
|
|
||||||
nj := jpgStem(jpgs[j])
|
|
||||||
if ni != nj {
|
|
||||||
return ni < nj
|
|
||||||
}
|
|
||||||
return jpgs[i] < jpgs[j]
|
|
||||||
})
|
|
||||||
for _, name := range jpgs {
|
|
||||||
full := filepath.Join(env.imagesPath, name)
|
|
||||||
imgBytes, err := os.ReadFile(full)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read ref image %s: %v", full, err)
|
|
||||||
}
|
|
||||||
ifw, err := mw.CreateFormFile("ref_images", name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create form file ref_images %s: %v", name, err)
|
|
||||||
}
|
|
||||||
if _, err := ifw.Write(imgBytes); err != nil {
|
|
||||||
t.Fatalf("write ref image %s: %v", name, err)
|
|
||||||
}
|
|
||||||
refCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := mw.Close(); err != nil {
|
|
||||||
t.Fatalf("close multipart writer: %v", err)
|
|
||||||
}
|
|
||||||
if refCount == 0 {
|
|
||||||
t.Fatalf("ref images 目錄 %s 沒有 .jpg 檔(預期 56 張)", env.imagesPath)
|
|
||||||
}
|
|
||||||
return buf.Bytes(), mw.FormDataContentType(), refCount
|
|
||||||
}
|
|
||||||
|
|
||||||
// jpgStem 取 "<n>.jpg" 的數字部分;非數字回 -1(排到最前,不影響正確性)。
|
|
||||||
func jpgStem(name string) int {
|
|
||||||
stem := name[:len(name)-len(filepath.Ext(name))]
|
|
||||||
n, err := strconv.Atoi(stem)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// E2E #1:連線 + 認證 + InitJob + GetJob poll(contract 主路徑)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestRealConverter_InitAndPollContract 驗 visionA ConverterClient 對真 stage 服務的主路徑:
|
|
||||||
//
|
|
||||||
// InitJob(真送 onnx + 56 圖)→ status=created/running + stage=onnx
|
|
||||||
// → GetJob poll 到 completed(stub 幾秒)
|
|
||||||
// → visionA 正確解析真服務 completed response(即使 result_object_keys=null、無 analysis_info
|
|
||||||
// 也不報錯、不 panic;InputShape/Classes/Framework 為零值)
|
|
||||||
//
|
|
||||||
// 連線 + 認證隱含驗證:InitJob 沒回 ErrConverterAuthFailed = API key 過認證、連得上。
|
|
||||||
func TestRealConverter_InitAndPollContract(t *testing.T) {
|
|
||||||
env := requireRealConvEnv(t)
|
|
||||||
client := newRealConverterClient(t, env)
|
|
||||||
|
|
||||||
body, contentType, refCount := buildRealInitBody(t, env)
|
|
||||||
t.Logf("組好 multipart body:%d bytes,ref_images=%d 張", len(body), refCount)
|
|
||||||
|
|
||||||
// ── InitJob(真送)──────────────────────────────────────────────────
|
|
||||||
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer initCancel()
|
|
||||||
|
|
||||||
cj, err := client.InitJob(initCtx, InitConverterJobReq{
|
|
||||||
UserID: realConvTestUserID,
|
|
||||||
Platform: "520",
|
|
||||||
SourceFilename: filepath.Base(env.onnxPath),
|
|
||||||
Body: bytes.NewReader(body),
|
|
||||||
BodyContentType: contentType,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// 認證失敗 → 明確訊息(最可能:API key 未對齊)
|
|
||||||
if errors.Is(err, ErrConverterAuthFailed) {
|
|
||||||
t.Fatalf("InitJob 認證失敗(API key 未對齊?):%v\n"+
|
|
||||||
"確認 %s 與 stage container CONVERTER_API_KEY 一致。", err, realConvAPIKeyEnv)
|
|
||||||
}
|
|
||||||
t.Fatalf("InitJob 失敗:%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 連線 + 認證 OK(沒回 auth error 就是過了)
|
|
||||||
if cj.JobID == "" {
|
|
||||||
t.Fatalf("InitJob 回的 job_id 為空:%+v", cj)
|
|
||||||
}
|
|
||||||
// 真服務回 status=created(手動實測);visionA client 透傳。亦容忍 running(race)。
|
|
||||||
if cj.Status != "created" && cj.Status != "running" {
|
|
||||||
t.Errorf("InitJob status 預期 created/running,得 %q(job=%+v)", cj.Status, cj)
|
|
||||||
}
|
|
||||||
if cj.Stage != "onnx" {
|
|
||||||
t.Errorf("InitJob stage 預期 onnx,得 %q", cj.Stage)
|
|
||||||
}
|
|
||||||
t.Logf("InitJob OK:job_id=%s status=%s stage=%s", cj.JobID, cj.Status, cj.Stage)
|
|
||||||
|
|
||||||
// ── GetJob poll 到 completed ───────────────────────────────────────
|
|
||||||
final := pollUntilTerminal(t, client, cj.JobID)
|
|
||||||
|
|
||||||
t.Logf("終態 job:status=%s stage=%q progress=%v input_filename=%q platform=%q",
|
|
||||||
final.Status, final.Stage, derefInt(final.Progress), final.SourceFilename, final.Platform)
|
|
||||||
|
|
||||||
// stub 模式預期 completed;若 failed 也不該 panic(contract 仍須能解析)
|
|
||||||
if final.Status != "completed" {
|
|
||||||
t.Errorf("poll 終態預期 completed(stub 模式幾秒完成),得 %q(error_code=%q msg=%q)",
|
|
||||||
final.Status, final.ErrorCode, final.ErrorMessage)
|
|
||||||
}
|
|
||||||
// completed 時真服務回 stage=null → visionA 解析成 ""(不報錯)
|
|
||||||
if final.Status == "completed" && final.Stage != "" {
|
|
||||||
t.Errorf("completed 時 stage 預期空字串(真服務回 null),得 %q", final.Stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 驗 stub 環境真實行為:analysis_info 缺 → B4 metadata 全零值,不報錯 ──
|
|
||||||
// 這是 mock 測不到的:真 stub 服務的 completed response 沒有 analysis_info,
|
|
||||||
// visionA toConverterJob 必須優雅留零值(防禦性),不能 panic / 報錯。
|
|
||||||
if final.InputShape != nil {
|
|
||||||
t.Logf("注意:真服務回了 analysis_info.input_shape=%v(stub 模式預期 nil;"+
|
|
||||||
"若轉檔端已串 analysis_info 則此為正常)", final.InputShape)
|
|
||||||
}
|
|
||||||
if len(final.Classes) != 0 {
|
|
||||||
t.Logf("注意:真服務回了 classes=%v(stub 模式預期空)", final.Classes)
|
|
||||||
}
|
|
||||||
// 不對 InputShape 斷言「必為 nil」——若轉檔端未來串好 analysis_info,這裡不該 fail;
|
|
||||||
// 重點是「解析不 panic」,能跑到這行就證明解析成功。
|
|
||||||
t.Logf("contract 驗證通過:visionA client 成功解析真服務 completed response,未 panic")
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollUntilTerminal 用 visionA GetJob 對真服務 poll 到 completed/failed 或 timeout。
|
|
||||||
func pollUntilTerminal(t *testing.T, client ConverterClient, jobID string) *ConverterJob {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(realConvPollTimeout)
|
|
||||||
var last *ConverterJob
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
cj, err := client.GetJob(ctx, jobID)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
// 真服務暫時 5xx → GetJob 內已 retry;這裡再容忍一次(記 log 續 poll)
|
|
||||||
t.Logf("GetJob 暫時失敗(續 poll):%v", err)
|
|
||||||
time.Sleep(realConvPollInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
last = cj
|
|
||||||
switch cj.Status {
|
|
||||||
case "completed", "failed":
|
|
||||||
return cj
|
|
||||||
default:
|
|
||||||
time.Sleep(realConvPollInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if last == nil {
|
|
||||||
t.Fatalf("poll job %s 超時且從未成功 GetJob", jobID)
|
|
||||||
}
|
|
||||||
t.Logf("poll 超時,回最後一次狀態:status=%s", last.Status)
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// E2E #2:promote 失敗路徑(stub 無真 nef → converter 500 → visionA 不崩)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestRealConverter_PromoteFailsGracefullyOnStub 驗「真服務失敗時 visionA 不崩」:
|
|
||||||
//
|
|
||||||
// stub 模式下 GET /result 回 15 bytes 佔位字串、promote 因無真 nef → converter 回 500
|
|
||||||
// `{error:{code:"internal_error",...}}`。visionA Promote 必須:
|
|
||||||
// - 不 panic
|
|
||||||
// - 把 500 正確包裝成 ErrConverterUnavailable(mapPromoteError 預設 5xx 分支)
|
|
||||||
//
|
|
||||||
// 這是有價值的 contract 測試:驗 visionA 對真服務 5xx 的錯誤處理鏈正確。
|
|
||||||
func TestRealConverter_PromoteFailsGracefullyOnStub(t *testing.T) {
|
|
||||||
env := requireRealConvEnv(t)
|
|
||||||
client := newRealConverterClient(t, env)
|
|
||||||
|
|
||||||
// 先 init + poll 到 completed(promote 前提:job 須 completed)
|
|
||||||
body, contentType, _ := buildRealInitBody(t, env)
|
|
||||||
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer initCancel()
|
|
||||||
cj, err := client.InitJob(initCtx, InitConverterJobReq{
|
|
||||||
UserID: realConvTestUserID,
|
|
||||||
Platform: "520",
|
|
||||||
SourceFilename: filepath.Base(env.onnxPath),
|
|
||||||
Body: bytes.NewReader(body),
|
|
||||||
BodyContentType: contentType,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("InitJob 失敗(promote 測試前置):%v", err)
|
|
||||||
}
|
|
||||||
final := pollUntilTerminal(t, client, cj.JobID)
|
|
||||||
if final.Status != "completed" {
|
|
||||||
t.Skipf("job 未 completed(status=%s),跳過 promote 失敗路徑驗證", final.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Promote — stub 無真 nef,預期 converter 回 500 ────────────────────
|
|
||||||
promoteCtx, promoteCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer promoteCancel()
|
|
||||||
|
|
||||||
// 包進 func 確保「不 panic」可被測到(panic 會讓 test fail 並印 stack)
|
|
||||||
res, perr := client.Promote(promoteCtx, cj.JobID, PromoteReq{
|
|
||||||
UserID: realConvTestUserID,
|
|
||||||
Source: "nef",
|
|
||||||
TargetObjectKey: "models/" + realConvTestUserID + "/" + cj.JobID + ".nef",
|
|
||||||
})
|
|
||||||
|
|
||||||
if perr == nil {
|
|
||||||
// 若 stage 某天切真模式 → promote 可能成功;不 fail(記 log,contract 仍成立)
|
|
||||||
t.Logf("注意:Promote 成功(stage 可能已非 stub 模式):%+v", res)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 預期:stub 無真 nef → converter 500 → visionA 包成 ErrConverterUnavailable
|
|
||||||
t.Logf("Promote 如預期失敗(stub 無真 nef):%v", perr)
|
|
||||||
if !errors.Is(perr, ErrConverterUnavailable) {
|
|
||||||
// 容忍其他合理 sentinel(如 ErrJobNotCompleted / ErrFAAUnavailable),但記下供人工判讀。
|
|
||||||
t.Logf("注意:Promote 錯誤非 ErrConverterUnavailable(得 %v)。"+
|
|
||||||
"確認真服務 500 body 是否符合手動實測的 internal_error 格式;"+
|
|
||||||
"若 converter 回了不同 status 此處需對齊。", perr)
|
|
||||||
}
|
|
||||||
// 關鍵 contract:visionA 回了「分類好的 error」而非 nil result + nil err(不崩)。
|
|
||||||
if res != nil {
|
|
||||||
t.Errorf("Promote 失敗時 result 應為 nil,得 %+v", res)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// E2E #3:GetResult 解析真 stub 輸出(15 bytes "STUB_NEF_OUTPUT")
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
// TestRealConverter_GetResultStubOutput 驗 visionA GetResult 對真 stub 服務的 streaming 解析:
|
|
||||||
//
|
|
||||||
// stub 模式 GET /result 回 15 bytes 字串 "STUB_NEF_OUTPUT"(非真 nef)。
|
|
||||||
// visionA GetResult 必須:
|
|
||||||
// - 不 panic
|
|
||||||
// - 回 io.ReadCloser stream + DownloadMetadata(能讀出 body、Close 正常)
|
|
||||||
//
|
|
||||||
// 這驗的是「真服務 result endpoint 的 streaming response 解析」——mock 用固定 marker,
|
|
||||||
// 此處用真服務的實際佔位輸出。
|
|
||||||
func TestRealConverter_GetResultStubOutput(t *testing.T) {
|
|
||||||
env := requireRealConvEnv(t)
|
|
||||||
client := newRealConverterClient(t, env)
|
|
||||||
|
|
||||||
body, contentType, _ := buildRealInitBody(t, env)
|
|
||||||
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer initCancel()
|
|
||||||
cj, err := client.InitJob(initCtx, InitConverterJobReq{
|
|
||||||
UserID: realConvTestUserID,
|
|
||||||
Platform: "520",
|
|
||||||
SourceFilename: filepath.Base(env.onnxPath),
|
|
||||||
Body: bytes.NewReader(body),
|
|
||||||
BodyContentType: contentType,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("InitJob 失敗(GetResult 測試前置):%v", err)
|
|
||||||
}
|
|
||||||
final := pollUntilTerminal(t, client, cj.JobID)
|
|
||||||
if final.Status != "completed" {
|
|
||||||
t.Skipf("job 未 completed(status=%s),跳過 GetResult 驗證", final.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
resCtx, resCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer resCancel()
|
|
||||||
stream, meta, gerr := client.GetResult(resCtx, cj.JobID)
|
|
||||||
if gerr != nil {
|
|
||||||
// stub 模式下 GET /result 預期回 200 + 佔位輸出;若回錯誤記下供人工判讀
|
|
||||||
t.Logf("注意:GetResult 失敗(stub 預期成功回佔位輸出):%v", gerr)
|
|
||||||
// 仍驗「失敗時不回半套」:stream 應為 nil
|
|
||||||
if stream != nil {
|
|
||||||
_ = stream.Close()
|
|
||||||
t.Errorf("GetResult 失敗時 stream 應為 nil")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
got, rerr := io.ReadAll(io.LimitReader(stream, 1024))
|
|
||||||
if rerr != nil {
|
|
||||||
t.Fatalf("讀 GetResult stream 失敗:%v", rerr)
|
|
||||||
}
|
|
||||||
t.Logf("GetResult OK:讀到 %d bytes,content_type=%q filename=%q content_length=%d。內容=%q",
|
|
||||||
len(got), meta.ContentType, meta.Filename, meta.ContentLength, string(got))
|
|
||||||
|
|
||||||
// stub 真實行為:15 bytes "STUB_NEF_OUTPUT"(手動實測)。不硬斷言內容(轉檔端可能改),
|
|
||||||
// 重點是「解析 streaming response 成功、能讀出 body」。
|
|
||||||
if len(got) == 0 {
|
|
||||||
t.Errorf("GetResult stream 讀到 0 bytes(stub 預期回 15 bytes 佔位輸出)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// helpers
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
func derefInt(p *int) int {
|
|
||||||
if p == nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return *p
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
//go:build dbtest
|
|
||||||
|
|
||||||
// redis_integration_test.go — RedisClient 對「真 Redis」的整合測試。
|
|
||||||
//
|
|
||||||
// owner: testing agent(internal/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.go(miniredis,預設可跑)互補: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() 可實際操作真 Redis(SET/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 隔離)")
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
// redis_test.go — RedisClient 的本機可跑測試(不需 docker)。
|
|
||||||
//
|
|
||||||
// owner: testing agent(internal/db redis 補測)
|
|
||||||
//
|
|
||||||
// 分類:
|
|
||||||
// - SafeRedisTarget:純函式 unit(log 安全字串、不含密碼)
|
|
||||||
// - NewRedisClient / Ping / Close / Client:用 in-process miniredis(純 Go,不需 docker)
|
|
||||||
// 驗證連線建立、啟動 ping、graceful close、底層 client 取得。
|
|
||||||
// - fail-fast:連不上的 host 應立即回 error(NewRedisClient 不靜默 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 — 純函式 unit(happy / 預設值 / 不含密碼)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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 path(miniredis)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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 path(fail-fast,不靜默 fallback)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestNewRedisClient_UnreachableHostFailsFast(t *testing.T) {
|
|
||||||
cfg := config.RedisConfig{
|
|
||||||
Host: "192.0.2.1", // TEST-NET-1(RFC 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 必須回 error(fail-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,
|
|
||||||
}
|
|
||||||
// 已取消的 context:ping 應立即失敗,不等滿 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 不應 panic(doc 承諾「重複呼叫安全」)。
|
|
||||||
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 應回 error(go-redis: client is closed)。
|
|
||||||
err = rc.Ping(context.Background())
|
|
||||||
assert.Error(t, err, "已關閉的 client Ping 應回 error")
|
|
||||||
}
|
|
||||||
@ -80,10 +80,6 @@ func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) (
|
|||||||
|
|
||||||
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
|
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
|
||||||
//
|
//
|
||||||
// 兩者都顯式斷言,避免任一方未來簽章漂移時只在 runtime 才爆。
|
// pgx.Tx 是 interface,無法直接取 (pgx.Tx)(nil) 當靜態斷言對象(nil interface 沒有具體型別),
|
||||||
// pgx.Tx 本身是 interface,typed-nil((pgx.Tx)(nil))可作為靜態斷言對象——編譯器只看
|
// 故只對 *pgxpool.Pool 做編譯期斷言;pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。
|
||||||
// 靜態型別是否滿足 Querier、不解參考該 nil 值,故安全。
|
var _ Querier = (*pgxpool.Pool)(nil)
|
||||||
var (
|
|
||||||
_ Querier = (*pgxpool.Pool)(nil)
|
|
||||||
_ Querier = (pgx.Tx)(nil)
|
|
||||||
)
|
|
||||||
|
|||||||
@ -174,11 +174,6 @@ func (r *InMemoryRepository) List(ctx context.Context, ownerUserID string) ([]*D
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save 新增或更新 device(upsert by ID)。
|
// Save 新增或更新 device(upsert 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 {
|
func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
|
||||||
if d == nil || d.ID == "" {
|
if d == nil || d.ID == "" {
|
||||||
return errors.New("device: Save requires non-nil device with ID")
|
return errors.New("device: Save requires non-nil device with ID")
|
||||||
@ -189,13 +184,6 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
// Copy 避免外部後續修改影響 store
|
// Copy 避免外部後續修改影響 store
|
||||||
cp := *d
|
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 {
|
if existing, ok := r.devices[d.ID]; ok && existing.DeletedAt == nil {
|
||||||
cp.CreatedAt = existing.CreatedAt // 保留原始 CreatedAt
|
cp.CreatedAt = existing.CreatedAt // 保留原始 CreatedAt
|
||||||
} else if cp.CreatedAt.IsZero() {
|
} else if cp.CreatedAt.IsZero() {
|
||||||
|
|||||||
@ -78,27 +78,7 @@ func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error
|
|||||||
// GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。
|
// GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。
|
||||||
//
|
//
|
||||||
// 對齊 in-memory:同一個 serial 在不同 owner 下不互相干擾(owner 過濾)。
|
// 對齊 in-memory:同一個 serial 在不同 owner 下不互相干擾(owner 過濾)。
|
||||||
//
|
|
||||||
// serial 空字串:Save 把空 serial 寫成 SQL NULL(見 SaveTx),故空 serial 查詢需用
|
|
||||||
// serial_number IS NULL 比對(等號比較永不命中 NULL)。非空 serial 走參數化 = $2。
|
|
||||||
// 此分支讓 PG 與 in-memory(d.SerialNumber == serial,空查空)語意一致。
|
|
||||||
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
|
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 + `
|
const q = `SELECT ` + deviceColumns + `
|
||||||
FROM devices
|
FROM devices
|
||||||
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
|
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
|
||||||
@ -153,23 +133,11 @@ func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*D
|
|||||||
// 重註冊(已 soft-delete 的 serial)走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT;
|
// 重註冊(已 soft-delete 的 serial)走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT;
|
||||||
// partial unique 因舊列已 deleted 不阻擋(見 package 註解)。
|
// partial unique 因舊列已 deleted 不阻擋(見 package 註解)。
|
||||||
func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
|
func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
|
||||||
return r.SaveTx(ctx, r.pool, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveTx 與 Save 相同的 upsert 語意,但在傳入的 Querier(pool 或 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 == "" {
|
if d == nil || d.ID == "" {
|
||||||
return errors.New("device: Save requires non-nil device with ID")
|
return errors.New("device: Save requires non-nil device with ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// remote_status / status 補預設值(offline / unknown),避免空字串寫進「有預設」的
|
// remote_status / status 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。
|
||||||
// NOT NULL 欄位(devices.remote_status / status 為 NOT NULL DEFAULT)後語意混淆。
|
|
||||||
// 此補預設行為與 InMemoryRepository.Save 一致(見 device.go),兩個實作對空狀態的
|
|
||||||
// 處理刻意對齊,讓同一筆空狀態 device 不論走 PG 或 in-memory 都讀回 offline / unknown。
|
|
||||||
remoteStatus := d.RemoteStatus
|
remoteStatus := d.RemoteStatus
|
||||||
if remoteStatus == "" {
|
if remoteStatus == "" {
|
||||||
remoteStatus = RemoteStatusOffline
|
remoteStatus = RemoteStatusOffline
|
||||||
@ -185,25 +153,7 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
|||||||
createdAt = d.CreatedAt.UTC()
|
createdAt = d.CreatedAt.UTC()
|
||||||
} // else: 留 nil → COALESCE($n, now())
|
} // else: 留 nil → COALESCE($n, now())
|
||||||
|
|
||||||
// serial_number:空字串寫成 SQL NULL(而非 '')。
|
const q = `
|
||||||
//
|
|
||||||
// 為什麼:devices.serial_number 是 nullable TEXT,partial 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 (
|
INSERT INTO devices (
|
||||||
id, owner_user_id, name, device_type, serial_number,
|
id, owner_user_id, name, device_type, serial_number,
|
||||||
remote_status, last_seen_at, last_connected_at, status,
|
remote_status, last_seen_at, last_connected_at, status,
|
||||||
@ -231,12 +181,12 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
|||||||
paired_at = EXCLUDED.paired_at,
|
paired_at = EXCLUDED.paired_at,
|
||||||
deleted_at = EXCLUDED.deleted_at`
|
deleted_at = EXCLUDED.deleted_at`
|
||||||
|
|
||||||
_, err := q.Exec(ctx, sql,
|
_, err := r.pool.Exec(ctx, q,
|
||||||
d.ID, // $1
|
d.ID, // $1
|
||||||
d.OwnerUserID, // $2
|
d.OwnerUserID, // $2
|
||||||
d.Name, // $3
|
d.Name, // $3
|
||||||
d.DeviceType, // $4
|
d.DeviceType, // $4
|
||||||
serialNumber, // $5
|
d.SerialNumber, // $5
|
||||||
remoteStatus, // $6
|
remoteStatus, // $6
|
||||||
d.LastSeenAt, // $7
|
d.LastSeenAt, // $7
|
||||||
d.LastConnectedAt, // $8
|
d.LastConnectedAt, // $8
|
||||||
|
|||||||
@ -360,10 +360,6 @@ func TestPG_ConcurrentRegisterSameSerial(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// 注意:errs 收集在各 goroutine(只寫各自 index、無共享寫衝突),但所有斷言
|
|
||||||
// 都在 Wait 後的主 goroutine 進行。require.* 會走 t.FailNow → runtime.Goexit,
|
|
||||||
// 只能在測試主 goroutine 呼叫;若放進上面的 spawn goroutine 會是未定義行為。
|
|
||||||
// 這是 Reviewer 標示的易退化點 —— 保持 require.ErrorAs 留在此迴圈(Wait 後、主 goroutine)。
|
|
||||||
var ok, conflict int
|
var ok, conflict int
|
||||||
for _, e := range errs {
|
for _, e := range errs {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
|
|||||||
@ -121,31 +121,6 @@ func TestPG_List_Filter(t *testing.T) {
|
|||||||
assert.Equal(t, id1, list[0].ID)
|
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)
|
|
||||||
// 最新在前(DESC):new → 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) {
|
func TestPG_Delete_SoftDelete(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
r, _, owner := newPGRepo(t)
|
r, _, owner := newPGRepo(t)
|
||||||
@ -242,8 +217,7 @@ func TestPG_Upsert_PreservesCreatedAt(t *testing.T) {
|
|||||||
assert.Equal(t, "v2", second.Name)
|
assert.Equal(t, "v2", second.Name)
|
||||||
assert.Equal(t, "k2", second.StorageKey)
|
assert.Equal(t, "k2", second.StorageKey)
|
||||||
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值")
|
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值")
|
||||||
// 已 sleep 10ms,updated_at 必嚴格推進(嚴格 After,非 After-OR-Equal)。
|
assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進")
|
||||||
assert.True(t, second.UpdatedAt.After(first.UpdatedAt), "updated_at 應嚴格推進(sleep 10ms 後)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。
|
// soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。
|
||||||
|
|||||||
@ -34,17 +34,6 @@ type ProviderConfig struct {
|
|||||||
// Scopes 是 OIDC scope 清單,預設 ["openid", "email", "profile"]。
|
// Scopes 是 OIDC scope 清單,預設 ["openid", "email", "profile"]。
|
||||||
// 若為空,NewProvider 會套用預設值。
|
// 若為空,NewProvider 會套用預設值。
|
||||||
Scopes []string
|
Scopes []string
|
||||||
|
|
||||||
// PromptLogin 控制 authorize request 是否帶 OIDC `prompt=login` 參數。
|
|
||||||
//
|
|
||||||
// - true → 每次登入都強制 IdP 重新認證(忽略既有 SSO session,要求重輸帳密)。
|
|
||||||
// - false → 不帶 prompt,沿用 IdP 既有 session(標準 SSO 體驗),為預設值。
|
|
||||||
//
|
|
||||||
// `prompt=login` 是 OIDC Core §3.1.2.1 標準參數,由 caller(OIDC discovery 不一定
|
|
||||||
// 在 prompt_values_supported 列出,但 Core 標準參數 IdP 多半支援)。設成 config 可控
|
|
||||||
// 是為了讓「強制重新認證」在不同部署間可開可關 — 例如 stage 要每次都問、prod 視 UX 決定。
|
|
||||||
// 對齊 VISIONA_OIDC_PROMPT_LOGIN。
|
|
||||||
PromptLogin bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultScopes 是 OIDC 標準 scope 集合,能取得 sub / email / name 三個 claim。
|
// DefaultScopes 是 OIDC 標準 scope 集合,能取得 sub / email / name 三個 claim。
|
||||||
|
|||||||
@ -136,19 +136,13 @@ func validateConfig(cfg *ProviderConfig) error {
|
|||||||
//
|
//
|
||||||
// 用 oauth2.Config.AuthCodeURL 組 URL,加上 PKCE 與 nonce 兩個額外參數
|
// 用 oauth2.Config.AuthCodeURL 組 URL,加上 PKCE 與 nonce 兩個額外參數
|
||||||
// (oauth2 lib 原生不知道這兩個東西,需以 oauth2.SetAuthURLParam 注入)。
|
// (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 {
|
func (p *provider) AuthorizationURL(state, nonce, codeChallenge string) string {
|
||||||
opts := []oauth2.AuthCodeOption{
|
return p.oauth2Cfg.AuthCodeURL(
|
||||||
|
state,
|
||||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||||
oauth2.SetAuthURLParam("nonce", nonce),
|
oauth2.SetAuthURLParam("nonce", nonce),
|
||||||
}
|
)
|
||||||
if p.cfg.PromptLogin {
|
|
||||||
opts = append(opts, oauth2.SetAuthURLParam("prompt", "login"))
|
|
||||||
}
|
|
||||||
return p.oauth2Cfg.AuthCodeURL(state, opts...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeCode 實作 Provider.ExchangeCode。
|
// ExchangeCode 實作 Provider.ExchangeCode。
|
||||||
|
|||||||
@ -331,50 +331,6 @@ func TestAuthorizationURL_Format(t *testing.T) {
|
|||||||
|
|
||||||
// authorization_endpoint 應指向 fake server 的 /authorize
|
// authorization_endpoint 應指向 fake server 的 /authorize
|
||||||
assert.Equal(t, fake.issuer()+"/authorize", u.Scheme+"://"+u.Host+u.Path)
|
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) {
|
func TestExchangeCode_Success(t *testing.T) {
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
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 agent(internal/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 session(server 給 LocalHandle,client 端配合)。
|
|
||||||
// 回傳 server session(LocalHandle 用)+ 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 應更新 LastHeartbeat:want %v got %v", newTime, after)
|
|
||||||
// ConnectedAt 不應被動到
|
|
||||||
assert.Equal(t, before, h.Summary().ConnectedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// OpenStream / IsClosed / Close — 跑在 net.Pipe 上的真 yamux(in-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")
|
|
||||||
}
|
|
||||||
@ -189,38 +189,6 @@ func TestInMemoryStore_List(t *testing.T) {
|
|||||||
assert.True(t, tokens["b"])
|
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) {
|
func TestInMemoryStore_CleanupExpired(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s := NewInMemoryStore()
|
s := NewInMemoryStore()
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
// 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 影響")
|
|
||||||
}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
// Package user 的 Postgres 持久層實作(DB-on FK 收尾,問題 #1)。
|
|
||||||
//
|
|
||||||
// PostgresStore 實作與 InMemoryStore 完全相同的 Store interface,讓 main.go 在 dbPool != nil
|
|
||||||
// 時無痛切換、OIDC callback 呼叫端一行都不需改。
|
|
||||||
//
|
|
||||||
// 對齊:
|
|
||||||
// - migrations/0001_create_users_models.up.sql(users 表: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 id(ON CONFLICT (id) DO UPDATE)保留 created_at
|
|
||||||
// - scan helper、nullable 欄位以指標接、NULL → 空字串
|
|
||||||
//
|
|
||||||
// email 唯一衝突語意(D1-B):
|
|
||||||
//
|
|
||||||
// upsert 以 id(OIDC sub)為衝突依據。若兩個不同 sub 共用同 email,會撞 uq_users_email_lower
|
|
||||||
// (lower(email) 唯一)→ INSERT/UPDATE 回 23505。雛形視為資料異常、原樣回錯(不靜默吞),
|
|
||||||
// 由 caller(OIDC callback)log 後回 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 新增或更新 user(upsert 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[] → []string(pgx 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
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
//go:build dbtest
|
|
||||||
|
|
||||||
// PostgresStore(user)的真 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-trip(id=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 sub(UUID 格式,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) unique(23505)。
|
|
||||||
//
|
|
||||||
// 這是 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 應掃回空字串")
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
// 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-B):Member Center 的 OIDC sub 確認是 UUID/GUID 格式,故 sub 可直接
|
|
||||||
// 當 users.id 主鍵 — 不需另開 oidc_sub 欄位、不需新 migration。callback 驗完 id_token 後
|
|
||||||
// 呼叫 Store.Upsert 把 user 落 DB(DB-on 時),in-memory 模式也呼叫對齊行為。
|
|
||||||
//
|
|
||||||
// 對齊 migrations/0001_create_users_models.up.sql 的 users 表 schema:
|
|
||||||
// - id UUID PK(D1-B 下即 OIDC sub)
|
|
||||||
// - email TEXT NOT NULL(uq_users_email_lower:lower(email) 唯一)
|
|
||||||
// - name TEXT(nullable)
|
|
||||||
// - 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-B:ID 即 OIDC sub(Member Center sub 為 UUID 格式,可直接當 PK)。
|
|
||||||
type User struct {
|
|
||||||
ID string `json:"id"` // = OIDC sub(UUID)
|
|
||||||
Email string `json:"email"` // NOT NULL;upsert 必給
|
|
||||||
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 持久層介面。
|
|
||||||
//
|
|
||||||
// 兩個實作:InMemoryStore(local-dev fallback / 單元測試)+ PostgresStore(DB-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 NULL);caller 須確保 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 = id(OIDC sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewInMemoryStore 建立一個空的記憶體 Store。
|
|
||||||
func NewInMemoryStore() *InMemoryStore {
|
|
||||||
return &InMemoryStore{users: make(map[string]*User)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert 新增或更新 user(by 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,避免外部後續修改影響 store(in-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)
|
|
||||||
@ -92,16 +92,7 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
|
|||||||
sess.Email = "real@example.com"
|
sess.Email = "real@example.com"
|
||||||
sess.OIDCCodeVerifier = "cv-secret"
|
sess.OIDCCodeVerifier = "cv-secret"
|
||||||
sess.AccessToken = "at-secret"
|
sess.AccessToken = "at-secret"
|
||||||
// 含非 ASCII(中文 + emoji + 特殊符號)的 Extra:驗證 JSON 在真 Redis(RESP binary
|
sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)}
|
||||||
// 傳輸)下 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 {
|
if err := store.Update(ctx, sess); err != nil {
|
||||||
t.Fatalf("Update: %v", err)
|
t.Fatalf("Update: %v", err)
|
||||||
}
|
}
|
||||||
@ -117,13 +108,6 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
|
|||||||
if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) {
|
if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) {
|
||||||
t.Fatalf("Extra round-trip mismatch: %+v", got.Extra)
|
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 直接驗)。
|
// key 帶 prefix(用底層 client 直接驗)。
|
||||||
if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 {
|
if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 {
|
||||||
|
|||||||
@ -37,14 +37,6 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** input_shape(NCHW,如 [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 {
|
interface ModelDetailClientProps {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@ -164,23 +156,6 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
|
|||||||
label={t("models.createdAt")}
|
label={t("models.createdAt")}
|
||||||
value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"}
|
value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"}
|
||||||
/>
|
/>
|
||||||
{/* B4 轉檔 metadata:有值才顯示(沒值時 backend omitempty,normalize 後為 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 && (
|
{selectedModel.version && (
|
||||||
<InfoRow label={t("models.detail.version")} value={selectedModel.version} />
|
<InfoRow label={t("models.detail.version")} value={selectedModel.version} />
|
||||||
)}
|
)}
|
||||||
@ -191,30 +166,6 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -195,10 +195,6 @@ export const en: Dictionary = {
|
|||||||
"models.detail.checksum": "Checksum",
|
"models.detail.checksum": "Checksum",
|
||||||
"models.detail.supportedChips": "Supported chips",
|
"models.detail.supportedChips": "Supported chips",
|
||||||
"models.detail.deployToDevice": "Deploy to device",
|
"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 ──
|
// ── Model Upload Dialog ──
|
||||||
"models.upload.button": "Upload model",
|
"models.upload.button": "Upload model",
|
||||||
|
|||||||
@ -196,10 +196,6 @@ export const zhHant: Dictionary = {
|
|||||||
"models.detail.checksum": "校驗碼",
|
"models.detail.checksum": "校驗碼",
|
||||||
"models.detail.supportedChips": "支援晶片",
|
"models.detail.supportedChips": "支援晶片",
|
||||||
"models.detail.deployToDevice": "部署至裝置",
|
"models.detail.deployToDevice": "部署至裝置",
|
||||||
"models.detail.inputShape": "輸入形狀",
|
|
||||||
"models.detail.framework": "來源框架",
|
|
||||||
"models.detail.classes": "類別",
|
|
||||||
"models.detail.classesCountSuffix": "個類別",
|
|
||||||
|
|
||||||
// ── Model Upload Dialog(flow-model-upload) ──
|
// ── Model Upload Dialog(flow-model-upload) ──
|
||||||
"models.upload.button": "上傳模型",
|
"models.upload.button": "上傳模型",
|
||||||
|
|||||||
@ -18,9 +18,6 @@ import { useAuthStore } from "./auth-store";
|
|||||||
* - fetchMe 等同 hydrate
|
* - fetchMe 等同 hydrate
|
||||||
* - logout 200 → 清 user
|
* - logout 200 → 清 user
|
||||||
* - logout backend 失敗仍清前端 user(best-effort)
|
* - logout backend 失敗仍清前端 user(best-effort)
|
||||||
* - logout 帶 idp_logout → 連動 MC 登出(建隱藏 iframe)
|
|
||||||
* - logout 無 idp_logout → 不建 iframe(向下相容)
|
|
||||||
* - logout iframe 被擋(onload 不觸發)→ fallback timer 後仍 resolve
|
|
||||||
* - 不再寫入 localStorage(OF6 安全債清理驗證)
|
* - 不再寫入 localStorage(OF6 安全債清理驗證)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -52,8 +49,6 @@ describe("auth-store", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
// 清掉任何測試殘留的 iframe(避免污染下一個測試的 querySelector)
|
|
||||||
document.querySelectorAll("iframe").forEach((el) => el.remove());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
@ -255,110 +250,6 @@ describe("auth-store", () => {
|
|||||||
|
|
||||||
await useAuthStore.getState().logout();
|
await useAuthStore.getState().logout();
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
expect(useAuthStore.getState().user).toBeNull();
|
||||||
// backend 失敗 → 拿不到 idp_logout → 不該建 iframe(退回只清 visionA)
|
|
||||||
expect(document.querySelector("iframe")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
|
||||||
/* logout — IdP(Member Center)連動登出 */
|
|
||||||
/* ------------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
it("logout:response 帶 idp_logout → 建隱藏 iframe 觸發 MC 登出,最終 resolve 並清掉 iframe", async () => {
|
|
||||||
useAuthStore.getState()._setUser({ id: "u", email: "e@x", name: "U" });
|
|
||||||
|
|
||||||
const mcLogoutUrl = "https://stage-9527.innovedus.com:7880/account/logout";
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
jsonResponse(200, {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
success: true,
|
|
||||||
idp_logout: { url: mcLogoutUrl, method: "GET" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// jsdom 不會真的載入 cross-origin iframe(onload 不觸發)→ 走 fallback timer。
|
|
||||||
// 用 fake timers 控制:logout 的 promise 會等到 timer 觸發才 resolve。
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const logoutPromise = useAuthStore.getState().logout();
|
|
||||||
|
|
||||||
// 等 backend POST + set state 完成(microtask flush),iframe 此時應已建立
|
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
|
||||||
|
|
||||||
const iframe = document.querySelector("iframe");
|
|
||||||
expect(iframe).not.toBeNull();
|
|
||||||
expect(iframe?.getAttribute("src")).toBe(mcLogoutUrl);
|
|
||||||
expect((iframe as HTMLIFrameElement).style.display).toBe("none");
|
|
||||||
|
|
||||||
// 前端 user 已清(在連動 MC 前就清了)
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
|
||||||
|
|
||||||
// 推進 fallback timer(2000ms)→ logout 應 resolve、iframe 被移除
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
|
||||||
await logoutPromise;
|
|
||||||
|
|
||||||
expect(document.querySelector("iframe")).toBeNull();
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout:iframe load 事件觸發時提早 resolve(不必等滿 fallback timer)", async () => {
|
|
||||||
useAuthStore.getState()._setUser({ id: "u" });
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
jsonResponse(200, {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
success: true,
|
|
||||||
idp_logout: { url: "https://mc.example/account/logout" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoutPromise = useAuthStore.getState().logout();
|
|
||||||
|
|
||||||
// 等 iframe 建立後(backend POST → res.text() → set state → triggerIdpLogout
|
|
||||||
// 之間有多個 microtask + macrotask),手動派發 load 事件模擬「MC 同源頁載入完成」
|
|
||||||
const iframe = await vi.waitFor(() => {
|
|
||||||
const el = document.querySelector("iframe");
|
|
||||||
if (!el) throw new Error("iframe not yet created");
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
iframe.dispatchEvent(new Event("load"));
|
|
||||||
|
|
||||||
await logoutPromise;
|
|
||||||
// load 觸發後 iframe 應被清掉
|
|
||||||
expect(document.querySelector("iframe")).toBeNull();
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout:response 無 idp_logout → 維持舊行為,不建 iframe(向下相容)", async () => {
|
|
||||||
useAuthStore.getState()._setUser({ id: "u", email: "e@x", name: "U" });
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
jsonResponse(200, { success: true, data: { success: true } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await useAuthStore.getState().logout();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
|
||||||
expect(document.querySelector("iframe")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout:idp_logout.url 為空字串 → 視為無,不建 iframe", async () => {
|
|
||||||
useAuthStore.getState()._setUser({ id: "u" });
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValue(
|
|
||||||
jsonResponse(200, {
|
|
||||||
success: true,
|
|
||||||
data: { success: true, idp_logout: { url: "", method: "GET" } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await useAuthStore.getState().logout();
|
|
||||||
expect(document.querySelector("iframe")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
|
|||||||
@ -9,8 +9,6 @@
|
|||||||
* 職責:
|
* 職責:
|
||||||
* - 持有當前使用者(從 backend `GET /api/auth/me` 取得)
|
* - 持有當前使用者(從 backend `GET /api/auth/me` 取得)
|
||||||
* - 提供 hydrate(app boot)/ fetchMe(手動 refresh)/ logout actions
|
* - 提供 hydrate(app boot)/ fetchMe(手動 refresh)/ logout actions
|
||||||
* - logout 連動 IdP(Member Center)登出:清掉 MC session 讓使用者能換帳號
|
|
||||||
* (backend logout response 回 `idp_logout.url`,前端用隱藏 iframe 背景觸發)
|
|
||||||
*
|
*
|
||||||
* BFF 模式重點:
|
* BFF 模式重點:
|
||||||
* - **frontend 完全看不到 OIDC token**(access_token / id_token 由 backend cookie session 持有)
|
* - **frontend 完全看不到 OIDC token**(access_token / id_token 由 backend cookie session 持有)
|
||||||
@ -47,36 +45,6 @@ interface MeResponse {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /api/auth/logout` 的 envelope `data` payload。
|
|
||||||
* 對齊 visionA-backend logout handler:
|
|
||||||
* { success: true, idp_logout?: { url, method } }
|
|
||||||
*
|
|
||||||
* - response 本身已清 visionA session cookie(前端不需再打)
|
|
||||||
* - `idp_logout` 為「連動 IdP(Member Center)登出」的資訊;**optional**:
|
|
||||||
* backend 未設 logout URL 時整個欄位缺席 → 前端維持「只清 visionA」的舊行為
|
|
||||||
* - `method` 目前固定為 GET(stage MC 直接導向即可),保留欄位以容忍未來改 POST
|
|
||||||
*/
|
|
||||||
interface LogoutResponse {
|
|
||||||
success?: boolean;
|
|
||||||
idp_logout?: {
|
|
||||||
url: string;
|
|
||||||
method?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 觸發 IdP(Member Center)登出後、再導向 visionA 登入頁的等待時間(毫秒)。
|
|
||||||
*
|
|
||||||
* 為什麼需要 fallback timer:
|
|
||||||
* - 隱藏 iframe 載入 MC :7880 logout 可能被 `X-Frame-Options` / CSP `frame-ancestors`
|
|
||||||
* 擋下 → `onload` 永遠不觸發;不能讓使用者卡死,所以無論如何 timer 到就往下走。
|
|
||||||
* - 即使 iframe 被擋,**瀏覽器仍會送出該 GET 請求**(X-Frame-Options 只擋「渲染」,
|
|
||||||
* 不擋請求發出),MC 端收到 logout request 後仍會清掉自己的 session cookie,
|
|
||||||
* 所以背景清登出的目的多半仍達成。
|
|
||||||
*/
|
|
||||||
const IDP_LOGOUT_FALLBACK_MS = 2000;
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Types */
|
/* Types */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@ -104,17 +72,8 @@ export interface AuthState {
|
|||||||
fetchMe: () => Promise<void>;
|
fetchMe: () => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登出:
|
* 登出:呼叫 backend `POST /api/auth/logout` 清 server session + cookie,
|
||||||
* 1. 呼叫 backend `POST /api/auth/logout` 清 server session + visiona_session cookie
|
* 不論成功與否最終都清前端 user state。
|
||||||
* 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>;
|
logout: () => Promise<void>;
|
||||||
|
|
||||||
@ -139,72 +98,6 @@ function mapMeToUser(me: MeResponse): User {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* IdP(Member Center)連動登出 */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用「不離開 visionA」的方式觸發 Member Center 登出,清掉 MC 的 session
|
|
||||||
* cookie,讓使用者下次登入時會被要求重新輸入帳密(才能換帳號)。
|
|
||||||
*
|
|
||||||
* 做法:動態建一個隱藏 iframe 指向 `url`(GET),瀏覽器送出該請求清 MC session。
|
|
||||||
* 全程使用者停留在 visionA、MC 登出在背景發生,不會把使用者甩到 MC 首頁
|
|
||||||
* (stage 的 MC 舊版不支援 returnUrl,直接 `location = url` 會卡在 MC 首頁)。
|
|
||||||
*
|
|
||||||
* resolve 時機(取最先發生者):
|
|
||||||
* 1. iframe `load` 事件觸發(MC 同源頁面載入完成、或瀏覽器判定載入結束)
|
|
||||||
* 2. fallback timer 到(iframe 被 X-Frame-Options / CSP 擋導致 load 不觸發時的保險)
|
|
||||||
*
|
|
||||||
* resolve 後會清掉 iframe(從 DOM 移除),避免殘留節點。
|
|
||||||
*
|
|
||||||
* 容錯:
|
|
||||||
* - 非瀏覽器環境(SSR / 測試無 document)→ 直接 resolve,不做任何事
|
|
||||||
* - 建立 iframe 過程任何例外 → 直接 resolve(登出不該因 MC 連動失敗而卡住)
|
|
||||||
*/
|
|
||||||
function triggerIdpLogout(url: string): Promise<void> {
|
|
||||||
// SSR / 無 DOM 環境(理論上 logout 只在 client 觸發,但保險處理)
|
|
||||||
if (typeof document === "undefined") {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
let iframe: HTMLIFrameElement | null = null;
|
|
||||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
if (timerId !== null) clearTimeout(timerId);
|
|
||||||
// 從 DOM 移除 iframe(保險用 optional chaining,避免 race)
|
|
||||||
if (iframe && iframe.parentNode) {
|
|
||||||
iframe.parentNode.removeChild(iframe);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
iframe = document.createElement("iframe");
|
|
||||||
iframe.style.display = "none";
|
|
||||||
// 安全性:限制 iframe 能力。allow-same-origin 必須保留,否則部分瀏覽器
|
|
||||||
// 不會送 MC 的 session cookie(cross-site cookie 需要 same-origin 上下文)。
|
|
||||||
iframe.setAttribute("aria-hidden", "true");
|
|
||||||
iframe.setAttribute("tabindex", "-1");
|
|
||||||
iframe.addEventListener("load", cleanup, { once: true });
|
|
||||||
// 設 src 觸發載入(GET)
|
|
||||||
iframe.src = url;
|
|
||||||
document.body.appendChild(iframe);
|
|
||||||
} catch {
|
|
||||||
// 建立 / 掛載失敗 → 不阻擋登出流程
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback:iframe 被擋(onload 不觸發)時,timer 到就往下走,避免卡死
|
|
||||||
timerId = setTimeout(cleanup, IDP_LOGOUT_FALLBACK_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Store */
|
/* Store */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@ -246,31 +139,15 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
|
|||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
let idpLogoutUrl: string | null = null;
|
|
||||||
try {
|
try {
|
||||||
// backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期,
|
// backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期
|
||||||
// 並(若有設定)回 idp_logout = { url, method } 供前端連動 MC 登出。
|
await api.post("/api/auth/logout");
|
||||||
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 {
|
} catch {
|
||||||
// 即使 backend 失敗(網路 / 5xx)— 仍然清前端 state,
|
// 即使 backend 失敗(網路 / 5xx)— 仍然清前端 state,
|
||||||
// 否則使用者卡在「無法登出」。下次發 API 若 cookie 還在就照樣帶,
|
// 否則使用者卡在「無法登出」。下次發 API 若 cookie 還在就照樣帶,
|
||||||
// backend session 已 best-effort 嘗試清除。失敗時拿不到 idp_logout,
|
// backend session 已 best-effort 嘗試清除。
|
||||||
// 退回「只清 visionA」行為。
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先清前端 user state(讓 UI 立即反映已登出),再背景連動 MC 登出。
|
|
||||||
set({ user: null, isLoading: false, error: null });
|
set({ user: null, isLoading: false, error: null });
|
||||||
|
|
||||||
// 有 idp_logout 才連動 MC;await 確保 caller 導向 /login 前 MC session 已(盡力)清完。
|
|
||||||
if (idpLogoutUrl) {
|
|
||||||
await triggerIdpLogout(idpLogoutUrl);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_setUser: (user) => {
|
_setUser: (user) => {
|
||||||
|
|||||||
@ -58,13 +58,6 @@ export interface Model extends ModelSummary {
|
|||||||
version?: string;
|
version?: string;
|
||||||
checksum?: string;
|
checksum?: string;
|
||||||
downloadUrl?: string; // presigned GET
|
downloadUrl?: string; // presigned GET
|
||||||
/**
|
|
||||||
* 轉檔 metadata(B4):值由 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) */
|
/** `POST /api/models/init` 的回傳(對齊 api-spec §4) */
|
||||||
@ -101,22 +94,6 @@ 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_shape:number[],過濾非有限數值;空陣列回 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 {
|
function normalizeModel(raw: unknown): Model {
|
||||||
const summary = normalizeModelSummary(raw);
|
const summary = normalizeModelSummary(raw);
|
||||||
const r = (raw ?? {}) as Record<string, unknown>;
|
const r = (raw ?? {}) as Record<string, unknown>;
|
||||||
@ -129,9 +106,6 @@ function normalizeModel(raw: unknown): Model {
|
|||||||
(r.download_url as string | undefined) ??
|
(r.download_url as string | undefined) ??
|
||||||
(r.downloadUrl as string | undefined) ??
|
(r.downloadUrl as string | undefined) ??
|
||||||
undefined,
|
undefined,
|
||||||
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
|
|
||||||
classes: normalizeStringArray(r.classes),
|
|
||||||
framework: (r.framework as string | undefined) ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user