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:
jim800121chen 2026-06-21 06:36:35 +08:00
parent 4d0b870480
commit cabbdde495
45 changed files with 4522 additions and 107 deletions

4
.gitignore vendored
View File

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

View File

@ -58,18 +58,30 @@ 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 healthcheckHost: localhost、來源 127.0.0.1
# 的 /healthz 也被打成 444 → container 長期 unhealthyfalse 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 給攻擊者反饋)
# 用 location / 包起來(而非 server-level return才不會 shadow 掉上面的 /healthz。
location / {
return 444; return 444;
} }
}
server { server {
listen 80; listen 80;
@ -109,7 +121,11 @@ server {
# 注意:不在這層加 HSTSHTTPS termination 在公司 host nginx由那層加 # 注意:不在這層加 HSTSHTTPS termination 在公司 host nginx由那層加
# ============================================================ # ============================================================
# 健康檢查 — 不打到 backenddocker 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健康探針不該 hangbackend 自身 ping 逾時也是 2s
# 這層再加一道 nginx 短逾時,避免單一探針卡住 worker。
# - access_log off高頻探針不洗版 access log與淺層一致
# - 不繼承 server-level 的 proxy_read_timeout 3600s那是給長連線用的
# ============================================================
location = /healthz/deep {
access_log off;
proxy_pass http://visiona_api/healthz;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# 健康探針要快、不該 hang —— 壓短所有 timeout覆蓋 server-level 3600s
proxy_connect_timeout 2s;
proxy_read_timeout 2s;
proxy_send_timeout 2s;
# 探針結果不可被任何中間層 cache
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-store" always;
}
# ============================================================ # ============================================================
# /api/* → api-server :3721 # /api/* → api-server :3721
# 包含 /api/auth/*OIDC callback、/api/devices、/api/models、/api/pairing 等 # 包含 /api/auth/*OIDC callback、/api/devices、/api/models、/api/pairing 等

View File

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

View File

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

View File

@ -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 模型 metadataoptional從 converter job analysis_info 串進來。
// 轉檔端尚未串好 analysis_info 時 rec.InputShape 等為零值nil / ""
// 這裡照樣賦值(賦 nil / "" 不影響建 modelDB 端寫 NULL / 空 INT[]。
InputShape: rec.InputShape,
Classes: rec.Classes,
Framework: rec.Framework,
Source: rec.Source, // 應為 "converted" Source: rec.Source, // 應為 "converted"
SourceJobID: rec.SourceJobID, SourceJobID: rec.SourceJobID,
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1promote 寫入的 FAA object key FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1promote 寫入的 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 模型 metadataoptional冪等回傳時帶回保持 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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,6 +51,7 @@ import (
"visiona-backend/internal/relay" "visiona-backend/internal/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,14 +218,17 @@ 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(),
@ -227,6 +237,10 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
MaxUploadSizeMB: maxUploadMB, MaxUploadSizeMB: maxUploadMB,
RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL
// DB-on FK 收尾in-memory user store問題 #1+ in-memory pairing exchanger問題 #2
UserStore: userStore,
PairingExchanger: api.NewInMemoryPairingExchanger(deviceRepo, sessionTokenStore),
// OIDC wiringOB5 // OIDC wiringOB5
OIDCProvider: oidcProvider, OIDCProvider: oidcProvider,
SessionManager: sessionMgr, SessionManager: sessionMgr,
@ -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,
} }
} }

View File

@ -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")
} }
// userDB-on FK 收尾(問題 #1— OIDC callback provision users 列用。
// dbPool != nil 時切到 PostgresStore否則 in-memorylocal-dev fallback
// D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID。callback 一行不需改地切換。
var userStore user.Store
if dbPool != nil {
userStore = user.NewPostgresStore(dbPool.Pool())
log.Info("user store initialized", "backend", "postgres")
} else {
userStore = user.NewInMemoryStore()
log.Info("user store initialized", "backend", "in-memory")
}
// pairing exchangerDB-on FK 收尾(問題 #2— exchange 時自建 device + 建 session token。
// - Postgresdb.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 注入 Deps.PairingExchangerexchange handler 偵測非 nil 即走「自建 device」路徑。
var pairingExchanger api.PairingExchanger
if dbPool != nil {
pairingExchanger = api.NewPostgresPairingExchanger(
dbPool.Pool(), pgDeviceRepo, pgSessionTokenStore, log)
log.Info("pairing exchanger initialized", "backend", "postgres-tx")
} else {
pairingExchanger = api.NewInMemoryPairingExchanger(
deviceRepo, memSessionTokenStore)
log.Info("pairing exchanger initialized", "backend", "in-memory")
}
// ===== ConverterstubPhase 2 才實作) ===== // ===== ConverterstubPhase 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塊 1seedDemoData 內部會先 ensure // dbPool 非 nil 時seed 的 model/device 走 Postgres塊 1+seedDemoData 內部會先透過
// demo user 列並改用合法 UUID owner / idnil 時維持雛形 in-memory 行為。 // userStore.Upsert ensure demo user 列並改用合法 UUID owner / idnil 時維持雛形 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)
} }
} }
@ -392,6 +415,7 @@ func main() {
DeviceRepo: deviceRepo, DeviceRepo: deviceRepo,
ModelRepo: modelRepo, ModelRepo: modelRepo,
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpairPostgres tx / in-memory 依序) DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpairPostgres tx / in-memory 依序)
PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2exchange 自建 device + session token
Storage: storageStore, Storage: storageStore,
Converter: converterClient, Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501 Conversion: conversionService, // Phase 0.8nil 時 /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 收尾 #1OIDC 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))

View File

@ -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_idUUID + FK故先 upsert demo user、改用 demoSeedUserID。 // 合法 UUID 與已存在的 owner_user_idUUID + FK故先 upsert demo user、改用 demoSeedUserID。
// dbPool 為 nilin-memory fallback時行為與雛形完全相同。 // dbBacked 為 falsein-memory fallback時行為與雛形完全相同。
//
// demo user 透過注入的 userStore.Upsert 落地DB-on 時 = PostgresStore滿足 models/devices/
// pairing_tokens 的 owner_user_id FK——與 OIDC callback 走同一條 provision 路徑,避免 seed
// 自己手拼 INSERT users 與 store 行為不一致(問題 #1 的 seed 對齊)。
func seedDemoData( 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.UpsertDB-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)
} }
} }

View File

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

View File

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

View File

@ -31,6 +31,7 @@ import (
"visiona-backend/internal/oidc" "visiona-backend/internal/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 成功後 provisionupsert一筆 users 列
// DB-on FK 收尾,問題 #1。D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID
//
// 為何必須DB-on有 FK真人登入後任何帶 owner_user_id FK 的寫入(上傳 model、配對、
// 發 pairing token都需要 users 表已有對應列;不 upsert → FK violation。
//
// 為 nil 時 callback 略過 upsert最小骨架 / 純 OIDC unit test 不強制注入);
// main.go 依 dbPool 注入 PostgresStore 或 InMemoryStore兩模式都會 upsert 對齊行為。
UserStore user.Store
SessionStore session.Store 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 收尾,問題 #2session_tokens.device_id NOT NULL FK但雛形流程從不建 device
// - Postgres用 db.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 為 nil 時 exchange handler fallback 到舊行為(直接用 info.DeviceID 建 session token
// 不自建 device——僅 DB-off 雛形相容用。main.go 依 dbPool 擇一注入。
PairingExchanger PairingExchanger
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴DB 接入塊 5.4)。 // 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」

View File

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

View File

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

View File

@ -60,6 +60,13 @@ type ModelResponse struct {
FileSize int64 `json:"file_size"` 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"
// 模型 metadataB4 鏈路最後一個序列化點)。
// snake_case 對齊本 DTO 既有慣例target_chip / file_size / created_at
// 前端 model-store normalize 雙吃 snake/camelinput_shape(snake) 在其讀取範圍內。
// omitempty上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。
InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
UploadedAt *time.Time `json:"uploaded_at,omitempty"` UploadedAt *time.Time `json:"uploaded_at,omitempty"`
@ -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-1storage 錯誤統一映射,不把 raw errbucket/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-1storage 錯誤統一映射,不把 raw errbucket/endpoint 等)洩漏給前端。
"stat storage failed: "+statErr.Error(), nil) WriteStorageError(c, deps.Logger, "stat object", statErr)
return return
} }
// Size 驗證(雛形只比對 sizePhase 1 加 checksum // Size 驗證(雛形只比對 sizePhase 1 加 checksum

View File

@ -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 endpointGET /api/models
// HTTP JSON 也帶 input_shape列表頁雖非 B4 主顯示處,仍確認鏈路一致)。
func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) {
r, repo, _ := newModelsFixture(t)
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "mdl-list-meta",
OwnerUserID: "demo-user",
Name: "listed",
Source: model.SourceConverted,
InputShape: []int{1, 28, 28},
Framework: "tflite",
}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
arr, ok := sb.Data.([]any)
require.True(t, ok)
require.Len(t, arr, 1)
first := arr[0].(map[string]any)
rawShape, present := first["input_shape"]
require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String())
shape, ok := rawShape.([]any)
require.True(t, ok)
require.Len(t, shape, 3)
assert.EqualValues(t, 1, shape[0])
assert.EqualValues(t, 28, shape[1])
assert.EqualValues(t, 28, shape[2])
assert.Equal(t, "tflite", first["framework"])
}
// TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。 // TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。
func TestModelsList_FiltersByOwner(t *testing.T) { func TestModelsList_FiltersByOwner(t *testing.T) {
r, repo, _ := newModelsFixture(t) r, repo, _ := newModelsFixture(t)

View File

@ -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-BOIDC sub 直接當 users.idMember Center sub 為 UUID。upsert 後 users 表才有
// 這筆使用者,後續帶 owner_user_id FK 的寫入model 上傳 / 配對 / pairing token才不會
// FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK僅維持對稱。
//
// 在「驗 id_token 成功後、寫 session 之前」provisionfail-closed —— provision 失敗就不發
// session否則使用者拿到能登入的 cookie 但 DB 沒對應 user下一個寫入照樣爆且更難診斷
// UserStore 為 nil最小骨架 / 純 OIDC unit test→ 略過 upsert。
if deps.UserStore != nil {
if upErr := deps.UserStore.Upsert(ctx, &user.User{
ID: claims.Subject, // = users.idD1-B
Email: claims.Email,
Name: claims.Name,
}); upErr != nil {
// 不洩漏 raw error 給 userlog 留診斷(不含 token / secret
log.Error("oidc.callback: provision user failed",
"request_id", RequestIDFrom(c),
"user_id", claims.Subject,
"error", upErr)
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to provision user", nil)
return
}
}
// Session fixation 防護OWASP ASVS V3.2.1)— Fix-A1 / Major-1。 // 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
} }

View File

@ -421,11 +421,43 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
return return
} }
// Generate Session Token // 自建 device + 建綁該 device 的 session tokenDB-on FK 收尾,問題 #2
plaintext, sessionInfo, err := deps.SessionTokenStore.Create( //
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
// 改由 PairingExchanger 在 exchange 時自建一筆 deviceowner = info.UserID該 user 已透過
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback
//
// info.TokenHash 作為 parent_token_hash稽核鏈session token ← pairing token
//
// PairingExchanger 為 nilDB-off 雛形最小骨架,未注入)→ fallback 到舊行為:直接用
// info.DeviceID可能為空建 session token不自建 device。in-memory store 不檢查 FK
// 空 deviceID 可接受;此分支只在 main.go 沒注入 exchanger 時走到。
var (
plaintext string
sessionInfo *auth.SessionToken
deviceID string
)
if deps.PairingExchanger != nil {
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL)
if exErr != nil {
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
"error", exErr,
"user_id", info.UserID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create session token", nil)
return
}
plaintext = res.SessionPlaintext
sessionInfo = res.SessionInfo
deviceID = res.DeviceID
} else {
var err error
plaintext, sessionInfo, err = deps.SessionTokenStore.Create(
ctx, ctx,
info.UserID, info.UserID,
info.DeviceID, // Pairing Token 雛形還沒綁 device_id為空沒關係 info.DeviceID, // fallback雛形未綁 device_id為空僅 in-memory 可接受)
info.TokenHash, info.TokenHash,
auth.SessionTokenTTL, auth.SessionTokenTTL,
) )
@ -437,6 +469,8 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
"failed to create session token", nil) "failed to create session token", nil)
return return
} }
deviceID = info.DeviceID
}
// Mark pairing token as used。 // Mark pairing token as used。
// //
@ -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 現在綁的是自建 deviceDB-on或 info.DeviceIDfallback
// 是安全的(它只是覆寫欄位)。 // 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 tokenrevoke 自身失敗不再 retry只 log。 // 嘗試 revoke 剛產生的 session tokenrevoke 自身失敗不再 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))

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,17 @@ const sessionColumns = `token_hash, user_id, device_id, parent_token_hash,
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用DB 不存)。 // 回傳的 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 相同的語意,但在傳入的 Querierpool 或 tx上執行。
//
// 用於 pairing exchange 自建 device 時,與 device 建立在同一交易內pairing 收尾問題 #2
// 「建 device + 建 session token」整筆原子。q 可為 *pgxpool.Pool自動 commit或 pgx.Tx
// 隨外層交易。device_id NOT NULLsession token 必綁 devicecaller 須先在同 tx 建好 device。
func (s *PostgresSessionTokenStore) CreateTx(
ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration,
) (string, *SessionToken, error) { ) (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)

View File

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

View File

@ -165,6 +165,23 @@ type ConverterJob struct {
ErrorCode string // 取自 error.code ErrorCode string // 取自 error.code
ErrorMessage string // 取自 error.message ErrorMessage string // 取自 error.message
TargetObjectKey string // 僅 promote 後才有GET / list 時為 "" TargetObjectKey string // 僅 promote 後才有GET / list 時為 ""
// ── 模型 metadataB4optional / 防禦性)─────────────────────────────────
//
// 來源:轉檔服務端 bie worker 的 analysis_infoservices/workers/bie/core.py
// 轉檔完成status=completedconverter 端**預期**會把 analysis_info 串進
// `GET /api/v1/jobs/{id}` 的 response見下方 converterJobJSON.AnalysisInfo 假設的 schema
//
// **這幾個欄位全部 optional**:轉檔端尚未串好 analysis_info 時(目前狀態),
// 這些欄位維持零值nil / ""),不影響建 modelInputShape 留空即可)。
//
// 轉檔端 result schema 對齊點(待轉檔端交接檔確認):見 converterJobJSON.AnalysisInfo。
//
// InputShape 對映bie worker 給的 {batch_size, channels, height, width}
// → []int{batch, channel, height, width}NCHW 順序,對齊 Model.InputShape 與 PG INT[] 既有測試 [1,3,224,224])。
InputShape []int // e.g. [1, 3, 224, 224]NCHW轉檔端沒給 → nil
Classes []string // 類別標籤;轉檔端沒給 → nil
Framework string // 原始框架("onnx" / "tflite" / ...);轉檔端沒給 → ""
} }
// ConverterPromoteResult 是 Promote 的 response shape。 // ConverterPromoteResult 是 Promote 的 response shape。
@ -675,6 +692,7 @@ 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,
@ -894,6 +912,7 @@ func extractActiveJobFromDetails(raw json.RawMessage) *Job {
// 為了同時支援: // 為了同時支援:
// - CreateJobResponsePOST /jobs 201— 無 stage_progress / input.filename 等欄位 // - CreateJobResponsePOST /jobs 201— 無 stage_progress / input.filename 等欄位
// - JobGET /jobs/{id})— 完整欄位 // - JobGET /jobs/{id})— 完整欄位
//
// 全部欄位都用 pointer 或 nullableMarshal 時靠下方 toConverterJob 統一轉。 // 全部欄位都用 pointer 或 nullableMarshal 時靠下方 toConverterJob 統一轉。
type converterJobJSON struct { type converterJobJSON struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
@ -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_infoinput_name/batch_size/channels/height/width
// visionA 同時接受兩種 input_shape 表示法(防呆,看轉檔端最後給哪種):
// (a) 明確的 `input_shape` 陣列 → 直接用
// (b) 拆開的 batch_size/channels/height/width → 組成 [batch, channel, height, width]
//
// **全部欄位 pointer / nullable**:轉檔端沒串好(目前狀態)→ AnalysisInfo 整個為 nil
// 或欄位為空 → 對映出的 metadata 全為零值,不報錯。
AnalysisInfo *converterAnalysisInfoJSON `json:"analysis_info"`
}
// converterAnalysisInfoJSON 是 B4 模型 metadata 的中介 unmarshal type。
//
// 對齊 converterJobJSON.AnalysisInfo 註解的假設 schema轉檔端待對齊
// 全欄位 pointer / slice — 任一缺漏都不影響解析(缺 → 零值)。
type converterAnalysisInfoJSON struct {
InputShape []int `json:"input_shape"` // 優先表示法
BatchSize *int `json:"batch_size"` // 後備:拆開的 4 維
Channels *int `json:"channels"`
Height *int `json:"height"`
Width *int `json:"width"`
Classes []string `json:"classes"`
Framework string `json:"framework"`
}
// toInputShape 把 analysis_info 對映成 Model.InputShape 用的 []intNCHW
//
// 優先序:
// 1. 明確的 input_shape 陣列(非空)→ 直接回傳
// 2. 後備batch_size/channels/height/width 四維**全部齊全**才組
// [batch, channel, height, width];任一缺 → 回 nil不亂組半套維度
// 3. 都沒有 → nil轉檔端尚未串好的目前狀態
func (a *converterAnalysisInfoJSON) toInputShape() []int {
if a == nil {
return nil
}
if len(a.InputShape) > 0 {
out := make([]int, len(a.InputShape))
copy(out, a.InputShape)
return out
}
if a.BatchSize != nil && a.Channels != nil && a.Height != nil && a.Width != nil {
return []int{*a.BatchSize, *a.Channels, *a.Height, *a.Width}
}
return nil
} }
// parseConverterJob 解 GET /api/v1/jobs/{id} 或 POST /api/v1/jobs 201 的 response。 // 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 模型 metadataoptional / 防禦性):轉檔端沒串 analysis_info → 全留零值。
if jr.AnalysisInfo != nil {
cj.InputShape = jr.AnalysisInfo.toInputShape()
if len(jr.AnalysisInfo.Classes) > 0 {
cj.Classes = append([]string(nil), jr.AnalysisInfo.Classes...)
}
cj.Framework = jr.AnalysisInfo.Framework
}
return cj return cj
} }

View File

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

View File

@ -82,6 +82,12 @@ type ModelRecord struct {
FileSize int64 FileSize int64
FileChecksum string FileChecksum string
TargetChip string TargetChip string
// ── 模型 metadataB4optional─────────────────────────────────────────
// 來源converter job 的 analysis_info轉檔端 bie worker。轉檔端尚未串好時
// 全留零值nil / ""adapter 對映到 model.Model 後一樣留空、不影響建 model。
InputShape []int // e.g. [1, 3, 224, 224]NCHW轉檔端沒給 → nil
Classes []string // 類別標籤;轉檔端沒給 → nil
Framework string // 原始框架;轉檔端沒給 → ""
Source string // 永遠 "converted" Source string // 永遠 "converted"
SourceJobID string SourceJobID string
// FAAObjectKey 是該 model 在 FAA 上的 object keyADR-017 (a) B1 // FAAObjectKey 是該 model 在 FAA 上的 object keyADR-017 (a) B1
@ -673,6 +679,12 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
FileSize: promoteRes.Size, 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 等為零值 →
// 這裡也維持零值,不影響建 modelInputShape 留空)。
InputShape: cj.InputShape,
Classes: cj.Classes,
Framework: cj.Framework,
Source: "converted", Source: "converted",
SourceJobID: jobID, SourceJobID: jobID,
FAAObjectKey: faaObjectKey, FAAObjectKey: faaObjectKey,
@ -841,6 +853,7 @@ 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 端對同一

View File

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

View File

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

View File

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

View File

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

View File

@ -174,6 +174,11 @@ func (r *InMemoryRepository) List(ctx context.Context, ownerUserID string) ([]*D
} }
// Save 新增或更新 deviceupsert by ID // Save 新增或更新 deviceupsert by ID
//
// remote_status / status 補預設值offline / unknown與 PostgresRepository.Save 一致:
// devices 表的這兩個欄位是 NOT NULL DEFAULT 'offline' / 'unknown'PG Save 對空值補預設後寫入。
// in-memory 在此同樣補預設,避免「同一筆空狀態 device 經 PG 讀出 offline/unknown、
// 經 in-memory 讀出空字串」的隱性落差(前端顯示 RemoteStatus見 api/devices.go
func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error { 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() {

View File

@ -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-memoryd.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 語意,但在傳入的 Querierpool 或 tx上執行。
//
// 用於 pairing exchange 自建 device 時,與 session token 建立在同一交易內pairing 收尾問題 #2
// 「建 device + 建 session token」整筆原子——任一步失敗整筆 rollback避免建了 device 卻沒建成
// session token 的中間態。q 可為 *pgxpool.Pool自動 commit或 pgx.Tx隨外層交易
func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device) error {
if d == nil || d.ID == "" { 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 TEXTpartial unique index
// uq_devices_owner_serial_active (owner_user_id, serial_number) WHERE deleted_at IS NULL
// 對「非 NULL」值才強制唯一。SQL 規範下每個 NULL 互不相等,故多筆「無序號」的
// device同 owner不互撞 unique但空字串 '' 是個確定值,同 owner 多筆 '' 會撞。
//
// 語意:實體裝置一定帶非空 serial防重複註冊照舊生效雲端 pairing exchange
// 自建的 device 沒有真實序號,正確表示是「無序號」(NULL) 而非 ''。如此同一 owner
// 多次 exchange 各建一筆 serial=NULL 的 distinct device、不撞 unique。
//
// 未來 local-tool 上報真實 serial 時,直接把這個 NULL 更新成真值即可,無 reconciliation
// 成本(若先前捏一個假 serial 佔位,反而要額外處理覆蓋)。
var serialNumber any
if d.SerialNumber != "" {
serialNumber = d.SerialNumber
} // else: 留 nil → 寫入 SQL NULL
const sql = `
INSERT INTO devices ( 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

View File

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

View File

@ -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)
// 最新在前DESCnew → mid → old。
assert.Equal(t, []string{idNew, idMid, idOld}, []string{list[0].ID, list[1].ID, list[2].ID},
"List 應依 created_at DESC最新在前排序")
// 額外以時間單調遞減交叉驗證,避免只靠 ID 對齊。
assert.True(t, list[0].CreatedAt.After(list[1].CreatedAt), "list[0] 應較 list[1] 新")
assert.True(t, list[1].CreatedAt.After(list[2].CreatedAt), "list[1] 應較 list[2] 新")
}
func TestPG_Delete_SoftDelete(t *testing.T) { 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 10msupdated_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非保留已刪除的舊值

View File

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

View File

@ -189,6 +189,38 @@ func TestInMemoryStore_List(t *testing.T) {
assert.True(t, tokens["b"]) 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()

View File

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

View File

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

View File

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

View File

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

View File

@ -92,7 +92,16 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
sess.Email = "real@example.com" sess.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 在真 RedisRESP binary
// 傳輸)下 UTF-8 round-trip 不被破壞。miniredis 不一定能暴露 binary 層問題,故在真 Redis 驗。
const cjkVal = "返回首頁/設定 ⚙️ — 名稱「測試」"
const cjkKey = "中文鍵"
sess.Extra = map[string]any{
"return_to": "/x",
"n": float64(7),
"zh": cjkVal,
cjkKey: "值🚀",
}
if err := store.Update(ctx, sess); err != nil { 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 {