feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d0b870480
commit
cabbdde495
4
.gitignore
vendored
4
.gitignore
vendored
@ -62,3 +62,7 @@ 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,17 +58,29 @@ 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;
|
||||||
# 直接內部回 200,不轉到 api-server(避免 api-server 也要實作 /healthz)
|
access_log off;
|
||||||
|
# 直接內部回 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 給攻擊者反饋)
|
||||||
return 444;
|
# 用 location / 包起來(而非 server-level return),才不會 shadow 掉上面的 /healthz。
|
||||||
|
location / {
|
||||||
|
return 444;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@ -109,7 +121,11 @@ server {
|
|||||||
# 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加)
|
# 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 健康檢查 — 不打到 backend,docker healthcheck 用
|
# 健康檢查(淺層)— 不打到 backend
|
||||||
|
# 用途:「nginx 程序活著」的最廉價證明。docker healthcheck(走 default_server
|
||||||
|
# 那條)與「只想確認反代層在線」的外部探針用這條。
|
||||||
|
# ⚠️ 注意:這條「不」反映 DB / Redis 健康 —— load balancer 若要在 DB 掛掉時
|
||||||
|
# 把本實例踢出輪替,必須打下面的 /healthz/deep,不能打這條。
|
||||||
# ============================================================
|
# ============================================================
|
||||||
location = /healthz {
|
location = /healthz {
|
||||||
access_log off;
|
access_log off;
|
||||||
@ -117,6 +133,38 @@ 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 等
|
||||||
|
|||||||
252
docs/autoflow/04-architecture/b4-converter-handoff.md
Normal file
252
docs/autoflow/04-architecture/b4-converter-handoff.md
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
# 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 那段寫,否則會漏。
|
||||||
221
docs/autoflow/07-delivery/deployment-guide.md
Normal file
221
docs/autoflow/07-delivery/deployment-guide.md
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# visionA 部署指南
|
||||||
|
|
||||||
|
> 本檔聚焦「DB 接入版 backend 部署到 stage 並接真 PG/Redis」的端到端流程與煙測結果。
|
||||||
|
> stage 基礎設定(host nginx、HTTPS termination、docker daemon 連線)另見同目錄 `stage-deployment-setup.md`;
|
||||||
|
> Phase 0.6 交接見 `phase-0.6-handover.md`。
|
||||||
|
|
||||||
|
## 1. 架構總覽(DB 接入後)
|
||||||
|
|
||||||
|
```
|
||||||
|
公司 host nginx (HTTPS termination, LE 證書)
|
||||||
|
│ stage-9527.innovedus.com:9527
|
||||||
|
▼
|
||||||
|
visiona container (image visiona:stage)
|
||||||
|
├─ 內層 nginx :80 ── 反代 ──► api-server :3721 (/api/*)
|
||||||
|
│ ── 反代 ──► frontend standalone (Next.js)
|
||||||
|
│ ── /healthz → return 200(淺層 liveness,不打 backend)
|
||||||
|
│ ── /healthz/deep → 反代 api-server /healthz(深層 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 冪等 |
|
||||||
@ -65,6 +65,12 @@ 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
|
||||||
@ -119,6 +125,10 @@ 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
|
||||||
|
|||||||
372
visionA-backend/cmd/api-server/conversion_adapters_test.go
Normal file
372
visionA-backend/cmd/api-server/conversion_adapters_test.go
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
// 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 應原樣往上傳")
|
||||||
|
}
|
||||||
89
visionA-backend/cmd/api-server/dbon_fk_fix_test.go
Normal file
89
visionA-backend/cmd/api-server/dbon_fk_fix_test.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// dbon_fk_fix_test.go — DB-on FK 收尾兩個問題的 integration 驗證(in-memory wiring)。
|
||||||
|
//
|
||||||
|
// 不帶 build tag:屬於預設 `go test ./...` 範圍。用 fixture 的 in-memory store + in-memory
|
||||||
|
// exchanger 走真實 OIDC login flow / pairing exchange flow,驗證:
|
||||||
|
// - 問題 #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")
|
||||||
|
}
|
||||||
309
visionA-backend/cmd/api-server/e2e_dbon_test.go
Normal file
309
visionA-backend/cmd/api-server/e2e_dbon_test.go
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
//go:build dbtest
|
||||||
|
|
||||||
|
// e2e_dbon_test.go — 塊 6 完整版(6.4 整鏈持久化 e2e + 6.5 DB-on 回歸驗證)。
|
||||||
|
//
|
||||||
|
// 這是「DB 接入」的最終 e2e 驗收:證明 6 個 store 都接上 Postgres / Redis 後,
|
||||||
|
//
|
||||||
|
// 1. 既有 e2e 路徑(OIDC 登入 → model 上傳 → 列 → get/ownership)在 DB-on 模式仍跑通;
|
||||||
|
// 2. **重啟(pool 重建)後資料還在**(持久化的核心承諾——in-memory 模式做不到);
|
||||||
|
// 3. **unpair device 後該 device 的 pairing + session token 真的被撤銷**(塊 5.2 cascade
|
||||||
|
// 的 e2e/store 層驗證,含直接查 DB 斷言 revoked_at / deleted_at)。
|
||||||
|
//
|
||||||
|
// build tag `dbtest`:需要 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
|
||||||
|
}
|
||||||
211
visionA-backend/cmd/api-server/fixture_dbon_test.go
Normal file
211
visionA-backend/cmd/api-server/fixture_dbon_test.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
//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,6 +51,7 @@ 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"
|
||||||
)
|
)
|
||||||
@ -107,6 +108,12 @@ 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
|
||||||
@ -211,21 +218,28 @@ 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: auth.NewInMemorySessionTokenStore(),
|
SessionTokenStore: sessionTokenStore,
|
||||||
SessionStore: sessionStore,
|
SessionStore: sessionStore,
|
||||||
Forwarder: forwarder,
|
Forwarder: forwarder,
|
||||||
DeviceRepo: device.NewInMemoryRepository(),
|
DeviceRepo: deviceRepo,
|
||||||
ModelRepo: model.NewInMemoryRepository(),
|
ModelRepo: model.NewInMemoryRepository(),
|
||||||
Storage: storeStore,
|
Storage: storeStore,
|
||||||
Converter: converter.NewStubClient(),
|
Converter: converter.NewStubClient(),
|
||||||
// Phase 0.7 security fix C1:StaticUserID 已從 Deps 移除(見 internal/api/api.go:77-80 註解)。
|
// Phase 0.7 security fix C1:StaticUserID 已從 Deps 移除(見 internal/api/api.go:77-80 註解)。
|
||||||
// 整合測試走 fixture.AuthenticatedClient 完整 OIDC login flow 取 cookie,不再走 fallback 捷徑。
|
// 整合測試走 fixture.AuthenticatedClient 完整 OIDC login flow 取 cookie,不再走 fallback 捷徑。
|
||||||
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,
|
||||||
@ -244,6 +258,8 @@ 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,7 +23,6 @@ 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"
|
||||||
@ -38,6 +37,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -265,6 +265,33 @@ 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()
|
||||||
|
|
||||||
@ -356,13 +383,9 @@ func main() {
|
|||||||
|
|
||||||
// ===== Seed demo data(可選) =====
|
// ===== Seed demo data(可選) =====
|
||||||
if cfg.Server.SeedDemoData {
|
if cfg.Server.SeedDemoData {
|
||||||
// dbPool 非 nil 時,seed 的 model 走 Postgres(塊 1):seedDemoData 內部會先 ensure
|
// dbPool 非 nil 時,seed 的 model/device 走 Postgres(塊 1+):seedDemoData 內部會先透過
|
||||||
// demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。
|
// userStore.Upsert ensure demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。
|
||||||
var seedPool *pgxpool.Pool
|
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, userStore, cfg.Auth.StaticUserID, dbPool != nil, log); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -391,7 +414,8 @@ func main() {
|
|||||||
Forwarder: forwarder,
|
Forwarder: forwarder,
|
||||||
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)
|
||||||
@ -409,6 +433,7 @@ func main() {
|
|||||||
OIDCProvider: oidcProvider,
|
OIDCProvider: oidcProvider,
|
||||||
SessionManager: userSessionMgr,
|
SessionManager: userSessionMgr,
|
||||||
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
|
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
|
||||||
|
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,15 +34,20 @@ const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3"
|
|||||||
// - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證)
|
// - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證)
|
||||||
// - **不要**在生產環境啟用此 flag
|
// - **不要**在生產環境啟用此 flag
|
||||||
//
|
//
|
||||||
// dbPool 非 nil 表 model repo 已切到 Postgres(塊 1):此時 seed 的 model 必須用合法 UUID
|
// dbBacked 為 true 表 repository 已切到 Postgres(塊 1+):此時 seed 的 model/device 必須用
|
||||||
// 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。
|
// 合法 UUID 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。
|
||||||
// dbPool 為 nil(in-memory fallback)時行為與雛形完全相同。
|
// dbBacked 為 false(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,
|
||||||
dbPool *pgxpool.Pool,
|
dbBacked bool,
|
||||||
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)
|
||||||
@ -61,17 +66,19 @@ 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 dbPool != nil {
|
if dbBacked {
|
||||||
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)。
|
||||||
if _, err := dbPool.Exec(ctx,
|
// 走 userStore.Upsert(DB-on = PostgresStore)與 OIDC callback 同一條 provision 路徑。
|
||||||
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
|
if err := userStore.Upsert(ctx, &user.User{
|
||||||
ON CONFLICT (id) DO NOTHING`,
|
ID: demoSeedUserID,
|
||||||
demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil {
|
Email: "demo@visiona.local",
|
||||||
|
Name: "Demo User (seeded)",
|
||||||
|
}); err != nil {
|
||||||
log.Warn("seed: ensure demo user failed", "error", err)
|
log.Warn("seed: ensure demo user failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
visionA-backend/cmd/api-server/seed_db_test.go
Normal file
110
visionA-backend/cmd/api-server/seed_db_test.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
165
visionA-backend/cmd/api-server/seed_test.go
Normal file
165
visionA-backend/cmd/api-server/seed_test.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// 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,6 +31,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -62,6 +63,16 @@ type Deps struct {
|
|||||||
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
|
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
|
||||||
OIDCPostLoginURL string
|
OIDCPostLoginURL 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
|
||||||
|
|
||||||
@ -119,6 +130,14 @@ 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,3 +99,28 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
172
visionA-backend/internal/api/errors_test.go
Normal file
172
visionA-backend/internal/api/errors_test.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@ -53,16 +53,23 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
|||||||
|
|
||||||
// ModelResponse 是 API 回傳的 Model DTO;對應 api-spec.md §4 的格式。
|
// ModelResponse 是 API 回傳的 Model DTO;對應 api-spec.md §4 的格式。
|
||||||
type ModelResponse struct {
|
type ModelResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
TargetChip string `json:"target_chip,omitempty"`
|
TargetChip string `json:"target_chip,omitempty"`
|
||||||
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"
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// 模型 metadata(B4 鏈路最後一個序列化點)。
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// snake_case 對齊本 DTO 既有慣例(target_chip / file_size / created_at);
|
||||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
// 前端 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"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// toModelResponse 把 domain model 轉為 API DTO;「status」由 UploadedAt 是否 set 決定。
|
// toModelResponse 把 domain model 轉為 API DTO;「status」由 UploadedAt 是否 set 決定。
|
||||||
@ -79,6 +86,9 @@ 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,
|
||||||
@ -244,8 +254,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 {
|
||||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
// 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。
|
||||||
"presigned url failed: "+err.Error(), nil)
|
WriteStorageError(c, deps.Logger, "presigned put url", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,8 +353,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
|
||||||
}
|
}
|
||||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
// 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。
|
||||||
"stat storage failed: "+statErr.Error(), nil)
|
WriteStorageError(c, deps.Logger, "stat object", statErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Size 驗證(雛形只比對 size;Phase 1 加 checksum)
|
// Size 驗證(雛形只比對 size;Phase 1 加 checksum)
|
||||||
|
|||||||
@ -183,6 +183,122 @@ 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,6 +32,7 @@ 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 的總時間。
|
||||||
@ -228,6 +229,32 @@ 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。
|
||||||
|
if deps.UserStore != nil {
|
||||||
|
if upErr := deps.UserStore.Upsert(ctx, &user.User{
|
||||||
|
ID: claims.Subject, // = users.id(D1-B)
|
||||||
|
Email: claims.Email,
|
||||||
|
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。
|
||||||
@ -402,4 +429,3 @@ func sanitizeReturnTo(raw string) string {
|
|||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -421,21 +421,55 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Session Token
|
// 自建 device + 建綁該 device 的 session token(DB-on FK 收尾,問題 #2)。
|
||||||
plaintext, sessionInfo, err := deps.SessionTokenStore.Create(
|
//
|
||||||
ctx,
|
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
|
||||||
info.UserID,
|
// 改由 PairingExchanger 在 exchange 時自建一筆 device(owner = info.UserID,該 user 已透過
|
||||||
info.DeviceID, // Pairing Token 雛形還沒綁 device_id,為空沒關係
|
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
|
||||||
info.TokenHash,
|
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback)。
|
||||||
auth.SessionTokenTTL,
|
//
|
||||||
|
// 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 err != nil {
|
if deps.PairingExchanger != nil {
|
||||||
logOrDefault(deps.Logger).Error("pairing exchange: create session token failed",
|
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL)
|
||||||
"error", err,
|
if exErr != nil {
|
||||||
"request_id", RequestIDFrom(c))
|
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
|
||||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
"error", exErr,
|
||||||
"failed to create session token", nil)
|
"user_id", info.UserID,
|
||||||
return
|
"request_id", RequestIDFrom(c))
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"failed to create session token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plaintext = res.SessionPlaintext
|
||||||
|
sessionInfo = res.SessionInfo
|
||||||
|
deviceID = res.DeviceID
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
plaintext, sessionInfo, err = deps.SessionTokenStore.Create(
|
||||||
|
ctx,
|
||||||
|
info.UserID,
|
||||||
|
info.DeviceID, // fallback:雛形未綁 device_id,為空(僅 in-memory 可接受)
|
||||||
|
info.TokenHash,
|
||||||
|
auth.SessionTokenTTL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logOrDefault(deps.Logger).Error("pairing exchange: create session token failed",
|
||||||
|
"error", err,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"failed to create session token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID = info.DeviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark pairing token as used。
|
// Mark pairing token as used。
|
||||||
@ -445,9 +479,9 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
// 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500,
|
// 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500,
|
||||||
// 而不是 silent log warn 繼續往前。
|
// 而不是 silent log warn 繼續往前。
|
||||||
//
|
//
|
||||||
// 注意:deviceID 沿用 info.DeviceID(可能為空)。雛形 MarkUsed 對空字串
|
// 注意:deviceID 現在綁的是自建 device(DB-on)或 info.DeviceID(fallback)。
|
||||||
// 是安全的(它只是覆寫欄位)。
|
// MarkUsed 把 pairing token 也綁上同一個 device_id(稽核:哪台 device 用掉了這個 token)。
|
||||||
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.DeviceID); err != nil {
|
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, 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",
|
||||||
@ -455,7 +489,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", info.DeviceID,
|
"device_id", 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)
|
||||||
@ -482,7 +516,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", info.DeviceID,
|
"device_id", 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))
|
||||||
|
|||||||
210
visionA-backend/internal/api/pairing_exchange.go
Normal file
210
visionA-backend/internal/api/pairing_exchange.go
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
// pairing_exchange.go — pairing exchange 自建 device 的協調者(DB-on FK 收尾,問題 #2)。
|
||||||
|
//
|
||||||
|
// 背景:
|
||||||
|
//
|
||||||
|
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
|
||||||
|
// production 路徑會建 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
|
||||||
|
}
|
||||||
118
visionA-backend/internal/api/pairing_exchange_db_test.go
Normal file
118
visionA-backend/internal/api/pairing_exchange_db_test.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//go:build dbtest
|
||||||
|
|
||||||
|
// Postgres pairing exchange 自建 device 的真 DB 整合測試(DB-on FK 收尾,問題 #2)。
|
||||||
|
//
|
||||||
|
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。
|
||||||
|
// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。
|
||||||
|
//
|
||||||
|
// 執行:
|
||||||
|
//
|
||||||
|
// go test -tags=dbtest ./internal/api/...
|
||||||
|
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||||||
|
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||||
|
// go test -tags=dbtest ./internal/api/...
|
||||||
|
//
|
||||||
|
// 涵蓋:
|
||||||
|
// - Provision 成功:自建一筆 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 應不同")
|
||||||
|
}
|
||||||
59
visionA-backend/internal/api/pairing_exchange_test.go
Normal file
59
visionA-backend/internal/api/pairing_exchange_test.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// memPairingExchanger 的單元測試(DB-on FK 收尾,問題 #2)。
|
||||||
|
//
|
||||||
|
// 不帶 build tag:屬於預設 `go test ./...` 範圍(無需 Docker)。
|
||||||
|
// 驗證 in-memory exchanger 自建 device + 建 session token、且 session token 綁到該 device,
|
||||||
|
// 與 pairing_exchange_db_test.go 的 Postgres dbtest 對齊行為。
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/auth"
|
||||||
|
"visiona-backend/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
devRepo := device.NewInMemoryRepository()
|
||||||
|
sessions := auth.NewInMemorySessionTokenStore()
|
||||||
|
exchanger := NewInMemoryPairingExchanger(devRepo, sessions)
|
||||||
|
|
||||||
|
res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, res.DeviceID)
|
||||||
|
require.NotEmpty(t, res.SessionPlaintext)
|
||||||
|
require.NotNil(t, res.SessionInfo)
|
||||||
|
|
||||||
|
// device 自建、owner 對齊
|
||||||
|
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "owner-1", dev.OwnerUserID)
|
||||||
|
assert.Equal(t, defaultPairedDeviceName, dev.Name)
|
||||||
|
assert.Equal(t, defaultPairedDeviceType, dev.DeviceType)
|
||||||
|
assert.NotNil(t, dev.PairedAt)
|
||||||
|
|
||||||
|
// session token 綁到自建 device
|
||||||
|
tok, err := sessions.Get(ctx, res.SessionPlaintext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 應綁到自建 device")
|
||||||
|
assert.Equal(t, "owner-1", tok.UserID)
|
||||||
|
assert.Equal(t, "parent-hash", tok.ParentTokenHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemExchange_Provision_DistinctDevices(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
exchanger := NewInMemoryPairingExchanger(
|
||||||
|
device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore())
|
||||||
|
|
||||||
|
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
||||||
|
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
|
||||||
|
}
|
||||||
307
visionA-backend/internal/api/storage_test.go
Normal file
307
visionA-backend/internal/api/storage_test.go
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
// storage_test.go — /storage/* presigned 代理 handler 的 unit test。
|
||||||
|
//
|
||||||
|
// Owner: testing agent(補測任務 — internal/api 弱處 handler)
|
||||||
|
//
|
||||||
|
// 測試對象:storage.go 的 storageGetHandler / storagePutHandler / verifyStorageSignature /
|
||||||
|
// storageKeyFromPath / registerStorageRoutes。
|
||||||
|
//
|
||||||
|
// 策略:用真 *storage.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,6 +57,17 @@ 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 {
|
||||||
@ -86,10 +97,10 @@ func (s *PostgresSessionTokenStore) Create(
|
|||||||
parentArg = parentTokenHash
|
parentArg = parentTokenHash
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = `INSERT INTO session_tokens
|
const sql = `INSERT INTO session_tokens
|
||||||
(token_hash, user_id, device_id, parent_token_hash, created_at, expires_at)
|
(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 := s.pool.Exec(ctx, q,
|
if _, err := q.Exec(ctx, sql,
|
||||||
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,10 +304,16 @@ func TestPGSession_ContextCancel(t *testing.T) {
|
|||||||
assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error")
|
assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tdbCountActive 計算某 owner 仍未過期(CleanupExpired 後殘留)的 session token 數。
|
// tdbCountActive 直接 SELECT count(*) 該 owner 在 session_tokens 表中殘留的列數。
|
||||||
// 用 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)
|
||||||
|
|||||||
@ -153,18 +153,35 @@ type PromoteReq struct {
|
|||||||
// 注意:這是 client 層的中間 type,flow.go 會轉成 conversion.Job(對 frontend 的 shape)。
|
// 注意:這是 client 層的中間 type,flow.go 會轉成 conversion.Job(對 frontend 的 shape)。
|
||||||
type ConverterJob struct {
|
type ConverterJob struct {
|
||||||
JobID string
|
JobID string
|
||||||
Status string // "created" / "running" / "completed" / "failed"
|
Status string // "created" / "running" / "completed" / "failed"
|
||||||
Stage string // "onnx" / "bie" / "nef";completed 時 converter 回 null → ""
|
Stage string // "onnx" / "bie" / "nef";completed 時 converter 回 null → ""
|
||||||
Progress *int // 整體 0-100;可能為 nil(converter 沒給)
|
Progress *int // 整體 0-100;可能為 nil(converter 沒給)
|
||||||
StageProgress *int // 當前 stage 0-100;可能為 nil
|
StageProgress *int // 當前 stage 0-100;可能為 nil
|
||||||
SourceFilename string // 取自 input.filename
|
SourceFilename string // 取自 input.filename
|
||||||
Platform string // 取自 parameters.platform
|
Platform string // 取自 parameters.platform
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
ExpiresAt time.Time // converter 沒給時上層自行 created_at + 7d 推算
|
ExpiresAt time.Time // converter 沒給時上層自行 created_at + 7d 推算
|
||||||
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。
|
||||||
@ -675,7 +692,8 @@ 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,
|
||||||
endpointKind, label string,
|
endpointKind, label string,
|
||||||
@ -894,13 +912,14 @@ 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"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Stage *string `json:"stage"` // completed 時 converter 回 null
|
Stage *string `json:"stage"` // completed 時 converter 回 null
|
||||||
Progress *int `json:"progress"`
|
Progress *int `json:"progress"`
|
||||||
StageProgress *int `json:"stage_progress"`
|
StageProgress *int `json:"stage_progress"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
@ -915,6 +934,67 @@ 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。
|
||||||
@ -953,6 +1033,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1209,9 +1297,9 @@ func parseFilenameFromContentDisposition(cd string) string {
|
|||||||
func parseConverterPromoteResult(body []byte) (*ConverterPromoteResult, error) {
|
func parseConverterPromoteResult(body []byte) (*ConverterPromoteResult, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Promoted []struct {
|
Promoted []struct {
|
||||||
TargetObjectKey string `json:"target_object_key"`
|
TargetObjectKey string `json:"target_object_key"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
FileAccessAgentETag string `json:"file_access_agent_etag"`
|
FileAccessAgentETag string `json:"file_access_agent_etag"`
|
||||||
} `json:"promoted"`
|
} `json:"promoted"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &resp); err != nil {
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
|||||||
@ -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,6 +1339,149 @@ 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,8 +82,14 @@ type ModelRecord struct {
|
|||||||
FileSize int64
|
FileSize int64
|
||||||
FileChecksum string
|
FileChecksum string
|
||||||
TargetChip string
|
TargetChip string
|
||||||
Source string // 永遠 "converted"
|
// ── 模型 metadata(B4,optional)─────────────────────────────────────────
|
||||||
SourceJobID string
|
// 來源:converter job 的 analysis_info(轉檔端 bie worker)。轉檔端尚未串好時
|
||||||
|
// 全留零值(nil / ""),adapter 對映到 model.Model 後一樣留空、不影響建 model。
|
||||||
|
InputShape []int // e.g. [1, 3, 224, 224](NCHW);轉檔端沒給 → nil
|
||||||
|
Classes []string // 類別標籤;轉檔端沒給 → nil
|
||||||
|
Framework string // 原始框架;轉檔端沒給 → ""
|
||||||
|
Source string // 永遠 "converted"
|
||||||
|
SourceJobID string
|
||||||
// FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。
|
// FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。
|
||||||
// = converter promote 的 target_object_key(buildTargetObjectKey:models/{userID}/{jobID}.nef)。
|
// = converter promote 的 target_object_key(buildTargetObjectKey:models/{userID}/{jobID}.nef)。
|
||||||
// PromoteToModels 寫入;adapter 對映到 model.Model.FAAObjectKey。
|
// PromoteToModels 寫入;adapter 對映到 model.Model.FAAObjectKey。
|
||||||
@ -223,16 +229,16 @@ var _ Service = (*flow)(nil)
|
|||||||
// 5. 寫 ownership.Set(jobID, userID)
|
// 5. 寫 ownership.Set(jobID, userID)
|
||||||
// 6. 失敗時的 cleanup 行為(§4.3.2):
|
// 6. 失敗時的 cleanup 行為(§4.3.2):
|
||||||
// - converter Phase 1 **沒有實作** `POST /api/v1/jobs/{id}/cancel` endpoint
|
// - converter Phase 1 **沒有實作** `POST /api/v1/jobs/{id}/cancel` endpoint
|
||||||
// (已驗證:apps/task-scheduler 的 routes/v1/jobs.js 只有 POST '/'、GET '/'、
|
// (已驗證:apps/task-scheduler 的 routes/v1/jobs.js 只有 POST '/'、GET '/'、
|
||||||
// GET '/:id'、POST '/:id/download-tokens'、DELETE '/:id')。
|
// GET '/:id'、POST '/:id/download-tokens'、DELETE '/:id')。
|
||||||
// - Phase 0.8 採「socket close 自然 abort」策略:streaming body 中斷時
|
// - Phase 0.8 採「socket close 自然 abort」策略:streaming body 中斷時
|
||||||
// converter multer 拋錯 → 該 job 留 `failed` 狀態 + error_code=invalid_multipart
|
// converter multer 拋錯 → 該 job 留 `failed` 狀態 + error_code=invalid_multipart
|
||||||
// → converter 對 active_job 邏輯視為已結束 → 下次 init 不會撞 409。
|
// → converter 對 active_job 邏輯視為已結束 → 下次 init 不會撞 409。
|
||||||
// - flow.go 不主動發 cancel(沒有對應 endpoint 可發);只在 InitJob 失敗時 log。
|
// - flow.go 不主動發 cancel(沒有對應 endpoint 可發);只在 InitJob 失敗時 log。
|
||||||
// - **Phase 1+ 升級**:當 converter 補上 `/cancel` 後,T3 ConverterClient
|
// - **Phase 1+ 升級**:當 converter 補上 `/cancel` 後,T3 ConverterClient
|
||||||
// 新增 `CancelJob(ctx, jobID) error`,flow.go 在 InitJob 失敗時開獨立 5s
|
// 新增 `CancelJob(ctx, jobID) error`,flow.go 在 InitJob 失敗時開獨立 5s
|
||||||
// timeout context(不繼承已 cancel 的 ctx)做 best-effort 主動 cancel。
|
// timeout context(不繼承已 cancel 的 ctx)做 best-effort 主動 cancel。
|
||||||
// 見 conversion.md §4.3.2 + ./05-implementation/phase-0.8-T6.md follow-ups。
|
// 見 conversion.md §4.3.2 + ./05-implementation/phase-0.8-T6.md follow-ups。
|
||||||
func (f *flow) InitJob(ctx context.Context, in InitJobInput) (*Job, error) {
|
func (f *flow) InitJob(ctx context.Context, in InitJobInput) (*Job, error) {
|
||||||
if in.UserID == "" {
|
if in.UserID == "" {
|
||||||
return nil, errors.New("conversion: InitJob requires UserID")
|
return nil, errors.New("conversion: InitJob requires UserID")
|
||||||
@ -673,6 +679,12 @@ 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,
|
||||||
@ -713,12 +725,12 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
|
|||||||
// 2. converter.GetJob — 確認 status=completed(否則 ErrJobNotCompleted)
|
// 2. converter.GetJob — 確認 status=completed(否則 ErrJobNotCompleted)
|
||||||
// 3. ensurePromoted — 自動觸發 promote 確保 converter MinIO 內有 NEF
|
// 3. ensurePromoted — 自動觸發 promote 確保 converter MinIO 內有 NEF
|
||||||
// - 設計選擇(沿用 Phase 0.8):自動觸發。理由:api-conversion.md §4 註解說
|
// - 設計選擇(沿用 Phase 0.8):自動觸發。理由:api-conversion.md §4 註解說
|
||||||
// 「兩條路徑(promote-to-models / download)都拿同一個 target_object_key」+
|
// 「兩條路徑(promote-to-models / download)都拿同一個 target_object_key」+
|
||||||
// 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted(冪等)」—
|
// 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted(冪等)」—
|
||||||
// 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。
|
// 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。
|
||||||
// - v0.6 同時保留此步驟的另一個理由:converter `GET /api/v1/jobs/{id}/result` 從
|
// - v0.6 同時保留此步驟的另一個理由:converter `GET /api/v1/jobs/{id}/result` 從
|
||||||
// converter MinIO get object;promote 是把 NEF 同步保留在 MinIO + 推到 FAA 的步驟,
|
// converter MinIO get object;promote 是把 NEF 同步保留在 MinIO + 推到 FAA 的步驟,
|
||||||
// 兩者順序固定(promote 先、GetResult 後)。
|
// 兩者順序固定(promote 先、GetResult 後)。
|
||||||
// 4. converter.GetResult(jobID) — 從 converter MinIO streaming pull NEF binary
|
// 4. converter.GetResult(jobID) — 從 converter MinIO streaming pull NEF binary
|
||||||
// (v0.6 取代原 faa.GetFile(targetObjectKey) — visionA 端不再直接打 FAA)
|
// (v0.6 取代原 faa.GetFile(targetObjectKey) — visionA 端不再直接打 FAA)
|
||||||
// 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil);caller(handler)負責 io.Copy 到 client + Close
|
// 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil);caller(handler)負責 io.Copy 到 client + Close
|
||||||
@ -841,7 +853,8 @@ 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 端對同一
|
||||||
// job 重複 promote 接受」)— 直接打 converter,重複呼叫成本低(同步等 1-2s)。
|
// job 重複 promote 接受」)— 直接打 converter,重複呼叫成本低(同步等 1-2s)。
|
||||||
|
|||||||
@ -15,11 +15,16 @@
|
|||||||
//
|
//
|
||||||
// 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
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -860,6 +865,71 @@ 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()
|
||||||
|
|||||||
151
visionA-backend/internal/db/redis_integration_test.go
Normal file
151
visionA-backend/internal/db/redis_integration_test.go
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
//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 隔離)")
|
||||||
|
}
|
||||||
187
visionA-backend/internal/db/redis_test.go
Normal file
187
visionA-backend/internal/db/redis_test.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// 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,6 +80,10 @@ func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) (
|
|||||||
|
|
||||||
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
|
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
|
||||||
//
|
//
|
||||||
// pgx.Tx 是 interface,無法直接取 (pgx.Tx)(nil) 當靜態斷言對象(nil interface 沒有具體型別),
|
// 兩者都顯式斷言,避免任一方未來簽章漂移時只在 runtime 才爆。
|
||||||
// 故只對 *pgxpool.Pool 做編譯期斷言;pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。
|
// pgx.Tx 本身是 interface,typed-nil((pgx.Tx)(nil))可作為靜態斷言對象——編譯器只看
|
||||||
var _ Querier = (*pgxpool.Pool)(nil)
|
// 靜態型別是否滿足 Querier、不解參考該 nil 值,故安全。
|
||||||
|
var (
|
||||||
|
_ Querier = (*pgxpool.Pool)(nil)
|
||||||
|
_ Querier = (pgx.Tx)(nil)
|
||||||
|
)
|
||||||
|
|||||||
@ -174,6 +174,11 @@ 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")
|
||||||
@ -184,6 +189,13 @@ 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,7 +78,27 @@ 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`
|
||||||
@ -133,11 +153,23 @@ 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 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。
|
// remote_status / status 補預設值(offline / unknown),避免空字串寫進「有預設」的
|
||||||
|
// NOT NULL 欄位(devices.remote_status / status 為 NOT NULL DEFAULT)後語意混淆。
|
||||||
|
// 此補預設行為與 InMemoryRepository.Save 一致(見 device.go),兩個實作對空狀態的
|
||||||
|
// 處理刻意對齊,讓同一筆空狀態 device 不論走 PG 或 in-memory 都讀回 offline / unknown。
|
||||||
remoteStatus := d.RemoteStatus
|
remoteStatus := d.RemoteStatus
|
||||||
if remoteStatus == "" {
|
if remoteStatus == "" {
|
||||||
remoteStatus = RemoteStatusOffline
|
remoteStatus = RemoteStatusOffline
|
||||||
@ -153,7 +185,25 @@ func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
|
|||||||
createdAt = d.CreatedAt.UTC()
|
createdAt = d.CreatedAt.UTC()
|
||||||
} // else: 留 nil → COALESCE($n, now())
|
} // else: 留 nil → COALESCE($n, now())
|
||||||
|
|
||||||
const q = `
|
// serial_number:空字串寫成 SQL NULL(而非 '')。
|
||||||
|
//
|
||||||
|
// 為什麼: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,
|
||||||
@ -181,12 +231,12 @@ func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
|
|||||||
paired_at = EXCLUDED.paired_at,
|
paired_at = EXCLUDED.paired_at,
|
||||||
deleted_at = EXCLUDED.deleted_at`
|
deleted_at = EXCLUDED.deleted_at`
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx, q,
|
_, err := q.Exec(ctx, sql,
|
||||||
d.ID, // $1
|
d.ID, // $1
|
||||||
d.OwnerUserID, // $2
|
d.OwnerUserID, // $2
|
||||||
d.Name, // $3
|
d.Name, // $3
|
||||||
d.DeviceType, // $4
|
d.DeviceType, // $4
|
||||||
d.SerialNumber, // $5
|
serialNumber, // $5
|
||||||
remoteStatus, // $6
|
remoteStatus, // $6
|
||||||
d.LastSeenAt, // $7
|
d.LastSeenAt, // $7
|
||||||
d.LastConnectedAt, // $8
|
d.LastConnectedAt, // $8
|
||||||
|
|||||||
@ -360,6 +360,10 @@ 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,6 +121,31 @@ 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)
|
||||||
@ -217,7 +242,8 @@ 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 應保留首次值")
|
||||||
assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進")
|
// 已 sleep 10ms,updated_at 必嚴格推進(嚴格 After,非 After-OR-Equal)。
|
||||||
|
assert.True(t, second.UpdatedAt.After(first.UpdatedAt), "updated_at 應嚴格推進(sleep 10ms 後)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。
|
// soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。
|
||||||
|
|||||||
164
visionA-backend/internal/relay/local_handle_test.go
Normal file
164
visionA-backend/internal/relay/local_handle_test.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/yamux"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// owner: testing 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,6 +189,38 @@ 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()
|
||||||
|
|||||||
81
visionA-backend/internal/user/inmemory_store_test.go
Normal file
81
visionA-backend/internal/user/inmemory_store_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// InMemoryStore 的單元測試(DB-on FK 收尾,問題 #1)。
|
||||||
|
//
|
||||||
|
// 不帶 build tag:屬於預設 `go test ./...` 範圍(無需 Docker)。
|
||||||
|
// 驗證 Upsert / Get 的核心語意,並與 postgres_store_db_test.go 的 dbtest 對齊行為。
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInMemoryStore_UpsertAndGet(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := NewInMemoryStore()
|
||||||
|
|
||||||
|
in := &User{ID: "sub-001", Email: "alice@example.com", Name: "Alice", Roles: []string{"admin"}}
|
||||||
|
require.NoError(t, s.Upsert(ctx, in))
|
||||||
|
|
||||||
|
got, err := s.Get(ctx, "sub-001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "sub-001", got.ID)
|
||||||
|
assert.Equal(t, "alice@example.com", got.Email)
|
||||||
|
assert.Equal(t, "Alice", got.Name)
|
||||||
|
assert.Equal(t, []string{"admin"}, got.Roles)
|
||||||
|
assert.False(t, got.CreatedAt.IsZero(), "CreatedAt 應自動填入")
|
||||||
|
assert.False(t, got.UpdatedAt.IsZero(), "UpdatedAt 應自動填入")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInMemoryStore_Upsert_UpdatesAndPreservesCreatedAt(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := NewInMemoryStore()
|
||||||
|
|
||||||
|
require.NoError(t, s.Upsert(ctx, &User{ID: "sub-002", Email: "old@example.com", Name: "Old"}))
|
||||||
|
first, err := s.Get(ctx, "sub-002")
|
||||||
|
require.NoError(t, err)
|
||||||
|
origCreated := first.CreatedAt
|
||||||
|
|
||||||
|
// 同 ID 再 upsert:更新 email/name,保留 created_at。
|
||||||
|
require.NoError(t, s.Upsert(ctx, &User{ID: "sub-002", Email: "new@example.com", Name: "New"}))
|
||||||
|
second, err := s.Get(ctx, "sub-002")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "new@example.com", second.Email, "email 應更新")
|
||||||
|
assert.Equal(t, "New", second.Name, "name 應更新")
|
||||||
|
assert.Equal(t, origCreated, second.CreatedAt, "created_at 應保留")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInMemoryStore_Get_NotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := NewInMemoryStore()
|
||||||
|
|
||||||
|
_, err := s.Get(ctx, "nope")
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInMemoryStore_Upsert_RequiresIDAndEmail(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := NewInMemoryStore()
|
||||||
|
|
||||||
|
assert.Error(t, s.Upsert(ctx, &User{ID: "", Email: "x@y.z"}), "缺 ID 應回錯")
|
||||||
|
assert.Error(t, s.Upsert(ctx, &User{ID: "sub", Email: ""}), "缺 email 應回錯(users.email NOT NULL)")
|
||||||
|
assert.Error(t, s.Upsert(ctx, nil), "nil 應回錯")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemoryStore_Upsert_RolesCopied 確認 Upsert/Get 對 roles 做 copy,
|
||||||
|
// 外部後續修改傳入 slice 不影響 store 內容(對齊 in-memory copy 語意)。
|
||||||
|
func TestInMemoryStore_Upsert_RolesCopied(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := NewInMemoryStore()
|
||||||
|
|
||||||
|
roles := []string{"a", "b"}
|
||||||
|
require.NoError(t, s.Upsert(ctx, &User{ID: "sub-003", Email: "c@d.e", Roles: roles}))
|
||||||
|
roles[0] = "MUTATED" // 外部改傳入 slice
|
||||||
|
|
||||||
|
got, err := s.Get(ctx, "sub-003")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"a", "b"}, got.Roles, "store 內 roles 不應被外部 mutation 影響")
|
||||||
|
}
|
||||||
149
visionA-backend/internal/user/postgres_store.go
Normal file
149
visionA-backend/internal/user/postgres_store.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// Package user 的 Postgres 持久層實作(DB-on FK 收尾,問題 #1)。
|
||||||
|
//
|
||||||
|
// PostgresStore 實作與 InMemoryStore 完全相同的 Store interface,讓 main.go 在 dbPool != nil
|
||||||
|
// 時無痛切換、OIDC callback 呼叫端一行都不需改。
|
||||||
|
//
|
||||||
|
// 對齊:
|
||||||
|
// - migrations/0001_create_users_models.up.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
|
||||||
|
}
|
||||||
138
visionA-backend/internal/user/postgres_store_db_test.go
Normal file
138
visionA-backend/internal/user/postgres_store_db_test.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
//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 應掃回空字串")
|
||||||
|
}
|
||||||
135
visionA-backend/internal/user/user.go
Normal file
135
visionA-backend/internal/user/user.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// Package user 定義 User domain model 與 Store 介面。
|
||||||
|
//
|
||||||
|
// 背景(DB-on FK 收尾,問題 #1):
|
||||||
|
//
|
||||||
|
// OIDC callback 驗 id_token 成功後只寫 cookie session,從不寫 users 表。DB-on(有 FK)下,
|
||||||
|
// 真人登入後任何「帶 owner_user_id FK」的寫入(上傳 model → models.owner_user_id、配對 →
|
||||||
|
// devices.owner_user_id、發 pairing token → pairing_tokens.user_id)都會 FK violation。
|
||||||
|
// in-memory 模式因為不檢查 FK 而藏住此問題。
|
||||||
|
//
|
||||||
|
// 修法(使用者拍板 D1-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,7 +92,16 @@ 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"
|
||||||
sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)}
|
// 含非 ASCII(中文 + emoji + 特殊符號)的 Extra:驗證 JSON 在真 Redis(RESP binary
|
||||||
|
// 傳輸)下 UTF-8 round-trip 不被破壞。miniredis 不一定能暴露 binary 層問題,故在真 Redis 驗。
|
||||||
|
const cjkVal = "返回首頁/設定 ⚙️ — 名稱「測試」"
|
||||||
|
const cjkKey = "中文鍵"
|
||||||
|
sess.Extra = map[string]any{
|
||||||
|
"return_to": "/x",
|
||||||
|
"n": float64(7),
|
||||||
|
"zh": cjkVal,
|
||||||
|
cjkKey: "值🚀",
|
||||||
|
}
|
||||||
if err := store.Update(ctx, sess); err != nil {
|
if err := store.Update(ctx, sess); err != nil {
|
||||||
t.Fatalf("Update: %v", err)
|
t.Fatalf("Update: %v", err)
|
||||||
}
|
}
|
||||||
@ -108,6 +117,13 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
|
|||||||
if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) {
|
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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user