From cabbdde4951d4b9f044cf7d0fb8e126fe89866b4 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 21 Jun 2026 06:36:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(visionA-backend):=20DB=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=BE=8C=E7=BA=8C=20=E2=80=94=20OIDC/pairing=20FK=20?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=20+=20B4=20metadata=20+=20nginx=20healthz=20?= =?UTF-8?q?+=20=E8=A3=9C=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 4 + docker/nginx.stage.conf | 54 ++- .../04-architecture/b4-converter-handoff.md | 252 ++++++++++++ docs/autoflow/07-delivery/deployment-guide.md | 221 +++++++++++ .../cmd/api-server/conversion_adapters.go | 10 + .../api-server/conversion_adapters_test.go | 372 ++++++++++++++++++ .../cmd/api-server/dbon_fk_fix_test.go | 89 +++++ .../cmd/api-server/e2e_dbon_test.go | 309 +++++++++++++++ .../cmd/api-server/fixture_dbon_test.go | 211 ++++++++++ .../cmd/api-server/integration_test.go | 22 +- visionA-backend/cmd/api-server/main.go | 43 +- visionA-backend/cmd/api-server/seed.go | 27 +- .../cmd/api-server/seed_db_test.go | 110 ++++++ visionA-backend/cmd/api-server/seed_test.go | 165 ++++++++ visionA-backend/internal/api/api.go | 19 + visionA-backend/internal/api/errors_db.go | 25 ++ visionA-backend/internal/api/errors_test.go | 172 ++++++++ visionA-backend/internal/api/models.go | 38 +- visionA-backend/internal/api/models_test.go | 116 ++++++ visionA-backend/internal/api/oidc_auth.go | 28 +- visionA-backend/internal/api/pairing.go | 72 +++- .../internal/api/pairing_exchange.go | 210 ++++++++++ .../internal/api/pairing_exchange_db_test.go | 118 ++++++ .../internal/api/pairing_exchange_test.go | 59 +++ visionA-backend/internal/api/storage_test.go | 307 +++++++++++++++ .../auth/postgres_session_token_store.go | 15 +- .../postgres_session_token_store_db_test.go | 10 +- .../internal/conversion/converter_client.go | 118 +++++- .../conversion/converter_client_test.go | 147 ++++++- visionA-backend/internal/conversion/flow.go | 43 +- .../internal/conversion/flow_test.go | 76 +++- .../internal/db/redis_integration_test.go | 151 +++++++ visionA-backend/internal/db/redis_test.go | 187 +++++++++ visionA-backend/internal/db/tx.go | 10 +- visionA-backend/internal/device/device.go | 12 + .../internal/device/postgres_repository.go | 58 ++- .../device/postgres_repository_db_test.go | 4 + .../model/postgres_repository_db_test.go | 28 +- .../internal/relay/local_handle_test.go | 164 ++++++++ .../internal/session/inmemory_store_test.go | 32 ++ .../internal/user/inmemory_store_test.go | 81 ++++ .../internal/user/postgres_store.go | 149 +++++++ .../internal/user/postgres_store_db_test.go | 138 +++++++ visionA-backend/internal/user/user.go | 135 +++++++ .../usersession/redis_integration_test.go | 18 +- 45 files changed, 4522 insertions(+), 107 deletions(-) create mode 100644 docs/autoflow/04-architecture/b4-converter-handoff.md create mode 100644 docs/autoflow/07-delivery/deployment-guide.md create mode 100644 visionA-backend/cmd/api-server/conversion_adapters_test.go create mode 100644 visionA-backend/cmd/api-server/dbon_fk_fix_test.go create mode 100644 visionA-backend/cmd/api-server/e2e_dbon_test.go create mode 100644 visionA-backend/cmd/api-server/fixture_dbon_test.go create mode 100644 visionA-backend/cmd/api-server/seed_db_test.go create mode 100644 visionA-backend/cmd/api-server/seed_test.go create mode 100644 visionA-backend/internal/api/errors_test.go create mode 100644 visionA-backend/internal/api/pairing_exchange.go create mode 100644 visionA-backend/internal/api/pairing_exchange_db_test.go create mode 100644 visionA-backend/internal/api/pairing_exchange_test.go create mode 100644 visionA-backend/internal/api/storage_test.go create mode 100644 visionA-backend/internal/db/redis_integration_test.go create mode 100644 visionA-backend/internal/db/redis_test.go create mode 100644 visionA-backend/internal/relay/local_handle_test.go create mode 100644 visionA-backend/internal/user/inmemory_store_test.go create mode 100644 visionA-backend/internal/user/postgres_store.go create mode 100644 visionA-backend/internal/user/postgres_store_db_test.go create mode 100644 visionA-backend/internal/user/user.go diff --git a/.gitignore b/.gitignore index 5623b76..f4e80e6 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ local-tool/visiona-local/payload/ # 共享文件(PRD / 設計 / 架構 / 交付)走 docs/ 進 git .autoflow/ graphify-out/ + +# sub-agent 工作 log(per-branch、不進 git) +.autoflow-logs/ +**/.autoflow-logs/ diff --git a/docker/nginx.stage.conf b/docker/nginx.stage.conf index cd76b1d..ed3a75b 100644 --- a/docker/nginx.stage.conf +++ b/docker/nginx.stage.conf @@ -58,17 +58,29 @@ server { # /healthz 例外:Docker healthcheck 從 container 內打 localhost/healthz # (Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任) # 限制 source = 127.0.0.0/8 防止外部偽造 Host 跳過白名單 + # + # ⚠️ 為什麼這層不能用 server-level `return 444`(修正前的 bug): + # server context 的 `return` 在 nginx rewrite phase 執行,會「先於」location + # 匹配短路掉所有請求 —— 包含這個 `location = /healthz`。修正前 default_server + # 結尾寫 `return 444;`,導致 docker healthcheck(Host: localhost、來源 127.0.0.1) + # 的 /healthz 也被打成 444 → container 長期 unhealthy(false 444)。 + # 正解:把 catch-all 444 收進 `location /`,讓 exact-match `location = /healthz` + # 依 nginx location 優先序勝出。 location = /healthz { allow 127.0.0.0/8; allow ::1/128; deny all; - # 直接內部回 200,不轉到 api-server(避免 api-server 也要實作 /healthz) + access_log off; + # 直接內部回 200,不轉到 api-server(淺層:只證明 nginx 活著) return 200 "ok\n"; add_header Content-Type "text/plain" always; } # 其他任何 host header 不符合白名單 → 444 close(不回 response 給攻擊者反饋) - return 444; + # 用 location / 包起來(而非 server-level return),才不會 shadow 掉上面的 /healthz。 + location / { + return 444; + } } server { @@ -109,7 +121,11 @@ server { # 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加) # ============================================================ - # 健康檢查 — 不打到 backend,docker healthcheck 用 + # 健康檢查(淺層)— 不打到 backend + # 用途:「nginx 程序活著」的最廉價證明。docker healthcheck(走 default_server + # 那條)與「只想確認反代層在線」的外部探針用這條。 + # ⚠️ 注意:這條「不」反映 DB / Redis 健康 —— load balancer 若要在 DB 掛掉時 + # 把本實例踢出輪替,必須打下面的 /healthz/deep,不能打這條。 # ============================================================ location = /healthz { access_log off; @@ -117,6 +133,38 @@ server { add_header Content-Type text/plain; } + # ============================================================ + # 健康檢查(深層)— proxy 到 backend /healthz,會 ping Postgres + Redis + # 用途:load balancer / 監控的 readiness 探針。 + # - PG + Redis 都健康 → 200 {"status":"ok","checks":{"postgres":"ok","redis":"ok"}} + # - 任一依賴 ping 失敗 → 503 {"status":"unavailable","checks":{...:"down"}} + # 讓上游 LB 在 DB 掛掉時把本實例拉出輪替,而非繼續送流量進來碰 503 / 假資料。 + # + # 設計取捨: + # - proxy_pass 改寫 path → 後端命中的是 /healthz(後端只實作這一個健康端點)。 + # - timeout 全部壓短(2s):健康探針不該 hang;backend 自身 ping 逾時也是 2s, + # 這層再加一道 nginx 短逾時,避免單一探針卡住 worker。 + # - access_log off:高頻探針不洗版 access log(與淺層一致)。 + # - 不繼承 server-level 的 proxy_read_timeout 3600s(那是給長連線用的)。 + # ============================================================ + location = /healthz/deep { + access_log off; + proxy_pass http://visiona_api/healthz; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + + # 健康探針要快、不該 hang —— 壓短所有 timeout(覆蓋 server-level 3600s) + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_send_timeout 2s; + + # 探針結果不可被任何中間層 cache + proxy_no_cache 1; + proxy_cache_bypass 1; + add_header Cache-Control "no-store" always; + } + # ============================================================ # /api/* → api-server :3721 # 包含 /api/auth/*(OIDC callback)、/api/devices、/api/models、/api/pairing 等 diff --git a/docs/autoflow/04-architecture/b4-converter-handoff.md b/docs/autoflow/04-architecture/b4-converter-handoff.md new file mode 100644 index 0000000..dd684ee --- /dev/null +++ b/docs/autoflow/04-architecture/b4-converter-handoff.md @@ -0,0 +1,252 @@ +# B4 交接檔:轉檔服務端串接 `analysis_info`(input_shape metadata) + +> **狀態**:Draft — 給 `kneron_model_converter` repo 的人實作 +> **作者**:Architect Agent(visionA) +> **最後更新**:2026-06-21 +> **跨 repo**:本檔在 visionA repo(`docs/autoflow/04-architecture/`),描述的改動全部在 `kneron_model_converter` repo + +--- + +## 1. 目標(一句話) + +讓 `kneron_model_converter` 的 **`GET /api/v1/jobs/{id}` response 帶上 top-level `analysis_info` 物件**(含 input_shape / 維度 metadata),使 visionA 能在轉檔完成後顯示 model 的 input_shape。 + +### 為什麼這份交接這麼短的工作量 + +**值已經 parse 出來了,只差「往上串」。** bie worker(`services/workers/bie/core.py`)在量化階段已從 ONNX graph 讀出 `batch_size / channels / height / width` 並放進 `process_bie_core()` 的 return dict 的 `analysis_info` 欄位(L87-93)。但這個 dict 從 worker 回到 consumer 後就被丟掉了 —— consumer 推 done event 時沒帶它,所以一路到 `GET /jobs/{id}` response 都沒有這個資訊。 + +**visionA 端已全部就緒、等接。** visionA backend 已實作好 `analysis_info` 的解析(`converter_client.go`),frontend 也已實作顯示。轉檔端串好的那一刻,visionA 自動就能顯示,不需要 visionA 再改任何 code。轉檔端**沒串好**時 visionA 維持空白、不報錯(全 optional)。 + +--- + +## 2. 完整鏈路缺口表 + +資料從 bie worker 一路傳到 `GET /jobs/{id}` response 要經過 4 個檔。目前**第 1 個檔已有值**,後面 3 個檔把值「丟掉」了。 + +| # | 檔案 | 現況 | 要改什麼 | +|---|------|------|---------| +| 1 | `services/workers/bie/core.py` | ✅ **已 parse**,`process_bie_core()` return dict 含 `analysis_info`(L87-93) | **不用改**(可選:直接補一個 `input_shape` 陣列欄位,見 §4) | +| 2 | `services/workers/consumer.py` | ❌ `_process_message` 拿到 `result`(L206)含 analysis_info,但 `_push_done(job_id, "ok")`(L212)沒帶它;`_push_done`(L175-186)組的 done message 也沒這欄位 | **改**:把 `result["analysis_info"]` 串進 done message | +| 3 | `apps/task-scheduler/src/services/doneListener.js` | ❌ 解析 done message 只取 `{ job_id, step, result, reason }`(L102),丟掉 analysis_info | **改**:解析出 `analysis_info`,傳給 `jobService.advanceJob` | +| 4a | `apps/task-scheduler/src/services/jobService.js` | ❌ `advanceJob(jobId, completedStage)`(L246)不接收也不持久化 analysis_info | **改**:`advanceJob` 多收一個 analysis_info 參數,寫進 job record(Redis) | +| 4b | `apps/task-scheduler/src/routes/v1/jobs.js` | ❌ `serializeJobForResponse(job)`(L151-227)組 GET response body 沒有 `analysis_info` 欄位 | **改**:在 response body 加 top-level `analysis_info`(completed 時帶,否則 null) | + +> **關鍵理解**:done event 是「stage 完成事件」,bie 是 pipeline 第 2 階段(onnx → **bie** → nef)。analysis_info 是 bie 階段產出的。**bie stage 完成時就要把 analysis_info 持久化到 job record**,這樣即使後面還有 nef 階段、job record 也已經帶著 analysis_info,等到整個 job COMPLETED 後 `GET /jobs/{id}` 就讀得到。 + +--- + +## 3. Result schema(**一字不差對齊 visionA backend 已定案的讀取格式**) + +visionA backend 在 `GET /api/v1/jobs/{id}` response 的 **top-level `analysis_info` 物件**(與 `input`/`parameters`/`error`/`result_object_keys` 同層)讀 metadata。 + +### 3.1 目標 JSON(GET /jobs/{id} response,節錄) + +```json +{ + "job_id": "abc-123", + "status": "completed", + "stage": null, + "progress": 100, + "input": { "...": "..." }, + "result_object_keys": { "...": "..." }, + "parameters": { "...": "..." }, + "error": null, + "analysis_info": { + "input_shape": [1, 3, 224, 224], + "batch_size": 1, + "channels": 3, + "height": 224, + "width": 224, + "classes": ["face", "person"], + "framework": "onnx" + } +} +``` + +### 3.2 欄位定義(對齊 visionA `converterAnalysisInfoJSON`) + +| 欄位 | 型別 | 必填 | 說明 | +|------|------|------|------| +| `input_shape` | `[]int` | optional | **優先表示法**。NCHW 順序,如 `[1, 3, 224, 224]`。給了這個,visionA 直接用 | +| `batch_size` | `int` | optional | **後備**:`input_shape` 缺時用這 4 個維度組 | +| `channels` | `int` | optional | 後備維度 | +| `height` | `int` | optional | 後備維度 | +| `width` | `int` | optional | 後備維度 | +| `classes` | `[]string` | optional | 分類標籤,如 `["face", "person"]` | +| `framework` | `string` | optional | 來源框架,如 `"onnx"` | + +### 3.3 visionA 端對映優先序(已實作、不會變) + +visionA 收到 `analysis_info` 後,這樣決定 input_shape: + +1. `input_shape` 陣列**非空** → 直接用 +2. 否則 `batch_size`/`channels`/`height`/`width` 四維**全齊** → 組 `[batch, channel, height, width]`(NCHW) +3. 四維**缺任一** → `nil`(不亂組半套維度,防呆) +4. 都沒有 → `nil` + +**全 optional**:轉檔端整個 `analysis_info` 不給、或給空物件 `{}`、或給部分欄位,visionA 都不報錯,維持空白顯示。 + +--- + +## 4. 兩種表示法都接受(轉檔端可二選一) + +bie worker 現在原生輸出的是**拆開的四維**(`batch_size`/`channels`/`height`/`width`,core.py L89-92),沒有 `input_shape` 陣列。 + +visionA **兩種都接**: + +| 轉檔端給法 | visionA 處理 | 建議 | +|-----------|-------------|------| +| (a) 直接給 `input_shape: [1,3,224,224]` 陣列 | 走優先序 #1,直接用 | ✅ **更推薦** — 語意最明確、未來若有非 4 維 shape(如 NLP 模型)也能表達 | +| (b) 給拆開的 `batch_size`/`channels`/`height`/`width` 四維 | 走優先序 #2,組成 NCHW | ✅ 也可 — bie worker 現成欄位,改動最小 | + +**最省事路徑(推薦)**:consumer / doneListener / jobService 在傳遞時,**原封不動把 bie worker 的 `analysis_info` dict 往上傳**(裡面已含 `batch_size`/`channels`/`height`/`width`),最後 `serializeJobForResponse` 直接吐出去。visionA 走後備路徑 #2 即可。如果順手在 core.py 補一個 `input_shape` 陣列欄位更好,但非必要。 + +> ⚠️ 注意:bie worker 的 `analysis_info` 還有一個 `input_name` 欄位(core.py L88)。visionA 不讀它,無害,可一起往上傳、也可在某一層 drop 掉,都行。 + +--- + +## 5. 各檔要改的細節 + +> 以下是「建議改法」,實際命名 / 風格依轉檔端 repo 慣例。核心是**讓 analysis_info 一路傳到 job record,再從 GET response 吐出**。 + +### 5.1 `services/workers/consumer.py`(檔 #2) + +`_push_done` 目前不帶 metadata。把 bie 的 analysis_info 串進去: + +```python +def _push_done(self, job_id, result, reason=None, analysis_info=None): + message = { + "job_id": job_id, + "step": self.stage, + "result": result, + "completed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + } + if reason: + message["reason"] = reason + if analysis_info: # ← 新增 + message["analysis_info"] = analysis_info + self.client.xadd("queue:done", {"data": json.dumps(message)}) +``` + +`_process_message` 成功路徑(L211-212)把 `result` 裡的 analysis_info 帶上: + +```python + result = self.process_fn(input_paths, output_path, parameters) + self._upload_output(job_id, job_dir) + logger.info(...) + self._push_done(job_id, "ok", analysis_info=result.get("analysis_info")) # ← 改 +``` + +> 只有 bie stage 的 `result` 有 analysis_info;onnx / nef stage 的 `result.get("analysis_info")` 是 `None`,`_push_done` 的 `if analysis_info` 自然 skip,無害。 + +### 5.2 `apps/task-scheduler/src/services/doneListener.js`(檔 #3) + +L101-102 解析 done message 時多解一個欄位,並傳給 advanceJob: + +```js +const data = JSON.parse(fields[1]); +const { job_id, step, result, reason, analysis_info } = data; // ← 多解 analysis_info + +if (result === 'ok') { + await jobService.advanceJob(job_id, step, analysis_info); // ← 多傳一個參數 +} else { + await jobService.failJob(job_id, step, reason || 'Unknown error'); +} +``` + +### 5.3 `apps/task-scheduler/src/services/jobService.js`(檔 #4a) + +`advanceJob` 多收 analysis_info,寫進 job record(持久化到 Redis): + +```js +async function advanceJob(jobId, completedStage, analysisInfo) { // ← 多收參數 + const job = await getJob(jobId); + if (!job) { /* ...既有... */ return; } + + const currentIndex = STAGES.indexOf(completedStage); + if (currentIndex < 0) { /* ...既有... */ return; } + + recordStageComplete(job, completedStage); + + // ← 新增:bie stage 帶 analysis_info 時,持久化到 job record + // (bie 是中間 stage;先寫進 record,等 job COMPLETED 後 GET 就讀得到) + if (analysisInfo && typeof analysisInfo === 'object') { + job.analysis_info = analysisInfo; + } + + // ...既有的推進 / COMPLETED 邏輯不變(setJob 會把 job.analysis_info 一起寫進 Redis)... +} +``` + +> 為什麼寫在 `advanceJob` 而非等 COMPLETED 才寫:bie 完成時呼叫的是 `advanceJob(jobId, 'bie', analysisInfo)`,此時 stage 推進到 nef、job 還沒 COMPLETED。把 analysis_info 在這一刻寫進 record,後續 nef 完成走 COMPLETED branch 時 record 已帶著它(`setJob` 是整個 record 序列化寫 Redis,不會掉欄位)。 + +### 5.4 `apps/task-scheduler/src/routes/v1/jobs.js`(檔 #4b) + +`serializeJobForResponse(job)`(L151-227)的 return 物件加一個 top-level `analysis_info`: + +```js + return { + job_id: job.job_id, + user_id: job.user_id || null, + status: externalStatus, + // ...既有欄位... + parameters, + metadata, + analysis_info: // ← 新增 top-level 欄位 + job.analysis_info && typeof job.analysis_info === 'object' + ? job.analysis_info + : null, + created_by_client_id: job.created_by_client_id || null, + }; +``` + +> - 放 top-level(與 `input`/`parameters`/`error` 同層),**不要**塞進 `metadata` 或 `parameters`,visionA 讀的是 top-level `analysis_info`。 +> - completed 與否都可以吐(visionA 只在 completed 後才查到 metadata;但即使 in_progress 帶上也無害,visionA 全 optional)。建議**有就吐、沒有回 null**,最簡單。 +> - 這個 helper 同時被 GET /:id 與 GET 列表(`listJobsHandler`)共用,改一處兩邊都生效。 + +--- + +## 6. 測試建議(轉檔端怎麼驗) + +### 6.1 端到端驗(最直接) + +1. 跑一個完整轉檔 job(onnx → bie → nef),等到 `status: completed` +2. `GET /api/v1/jobs/{id}`,確認 response **top-level 有 `analysis_info`**,且 `batch_size`/`channels`/`height`/`width`(或 `input_shape`)有實際數值(不是 null / 0) +3. 確認數值與 bie worker log 印的維度一致(core.py L44-47 讀到的值) + +### 6.2 單元 / 整合驗(對齊既有 test 風格) + +- `jobService.advanceJob` test:傳 `analysisInfo` → `getJob` 後確認 `job.analysis_info` 有寫進去;不傳 → `job.analysis_info` 維持 undefined +- `serializeJobForResponse` test:job record 有 `analysis_info` → output 有 top-level `analysis_info`;沒有 → output 為 `null` +- 既有 `getJobs.integration.test.js` 可加一個 case:job record 帶 analysis_info → GET response 帶 analysis_info + +### 6.3 回歸(不要弄壞既有) + +- onnx / nef stage 的 done event **不帶** analysis_info → `advanceJob` 不應因 undefined 參數報錯(`if (analysisInfo && ...)` guard) +- 既有 job record(無 `analysis_info` 欄位)→ GET response 的 `analysis_info` 回 `null`,不報錯 +- `failJob` 路徑不受影響(fail 不帶 analysis_info) + +--- + +## 7. 跨 repo 對接點(給轉檔端參考) + +visionA 端讀取位置(**轉檔端不用改,僅供對照確認 schema 對齊**): + +| 項目 | 位置 | +|------|------| +| visionA unmarshal type | `visionA-backend/internal/conversion/converter_client.go` → `converterJobJSON.AnalysisInfo`(L961)/ `converterAnalysisInfoJSON`(L968-976) | +| visionA 對映邏輯 | 同檔 `toInputShape()`(L985-998)+ `parseConverterJob`(L1037-1042) | +| visionA 既有對齊測試 | `visionA-backend/internal/conversion/converter_client_test.go`(L1343-1482,4 個 case:明確 input_shape / 四維後備 / 部分維度防呆 / 缺 analysis_info) | +| visionA struct tag(權威 schema) | `input_shape` / `batch_size` / `channels` / `height` / `width` / `classes` / `framework`(converter_client.go L969-975) | + +**驗證對齊的最快方法**:把 §3.1 的 JSON 丟進 visionA 的 `parseConverterJob`(或對照 `converter_client_test.go` L1361-1372 的 test fixture),確認 `cj.InputShape == [1,3,224,224]`。轉檔端 GET response 只要長得跟那個 test fixture 一樣,就一定對得上。 + +--- + +## 8. 額外發現 / 注意事項 + +1. **bie worker 多一個 `input_name` 欄位**(core.py L88):visionA 不讀,往上傳無害,drop 也無害。 +2. **bie worker 假設輸入是 4 維 NCHW**(core.py L44-47 直接 index `dim[0..3]`):對非 4 維模型(如某些 NLP / 1D 模型)會 IndexError。這是**既有行為、不在本次交接範圍**,但若未來要支援非影像模型,core.py 這段要加防呆。提醒一下、不必現在處理。 +3. **classes / framework 目前 bie worker 沒產**:core.py 的 analysis_info 只有 input_name + 四維,沒有 `classes` / `framework`。visionA 這兩個欄位全 optional,轉檔端要不要補由你決定。若不補,visionA 對應欄位留空,不影響 input_shape 顯示。 +4. **done event 的 at-least-once 語意**:doneListener 的 ACK 在 try 內(advanceJob throw 時不 ACK → 重投遞)。`advanceJob` 寫 analysis_info 是冪等的(同一份 record 重寫同樣的 analysis_info),重投遞不會出問題,符合既有設計。 +5. **stage 順序**:pipeline 是 `onnx → bie → nef`(jobService.js L46 `STAGES`)。analysis_info 在 bie stage 產出,bie 完成時走的是 `advanceJob`(推進到 nef)而非 COMPLETED branch,所以**一定要在 advanceJob 寫**,不能只在 COMPLETED 那段寫,否則會漏。 diff --git a/docs/autoflow/07-delivery/deployment-guide.md b/docs/autoflow/07-delivery/deployment-guide.md new file mode 100644 index 0000000..5b4074a --- /dev/null +++ b/docs/autoflow/07-delivery/deployment-guide.md @@ -0,0 +1,221 @@ +# visionA 部署指南 + +> 本檔聚焦「DB 接入版 backend 部署到 stage 並接真 PG/Redis」的端到端流程與煙測結果。 +> stage 基礎設定(host nginx、HTTPS termination、docker daemon 連線)另見同目錄 `stage-deployment-setup.md`; +> Phase 0.6 交接見 `phase-0.6-handover.md`。 + +## 1. 架構總覽(DB 接入後) + +``` +公司 host nginx (HTTPS termination, LE 證書) + │ stage-9527.innovedus.com:9527 + ▼ +visiona container (image visiona:stage) + ├─ 內層 nginx :80 ── 反代 ──► api-server :3721 (/api/*) + │ ── 反代 ──► frontend standalone (Next.js) + │ ── /healthz → return 200(淺層 liveness,不打 backend) + │ ── /healthz/deep → 反代 api-server /healthz(深層 readiness,ping PG+Redis)見 §5/§9 + ├─ api-server :3721 + │ ├─ Postgres pool ──► 192.168.0.130:5432/visiona(真 PG) + │ └─ Redis client ──► visiona-redis:6379/0(真 Redis) + └─ remote-proxy :3800/:3801(tunnel) + │ + docker network: visiona-stage_default + ├─ visiona 172.19.0.2 + └─ visiona-redis (同網段,hostname 解析靠這個 network) +``` + +DB-on 啟用條件(`.env.stage` 已設): +- Postgres:`VISIONA_DB_HOST` + `VISIONA_DB_USER` + `VISIONA_DB_NAME` 非空 → 自動建池 + auto-migrate + repository 切 Postgres。 +- Redis:`VISIONA_REDIS_HOST` 非空(無密碼也算啟用)→ userSession 切 Redis、cookie session 持久化。 +- `VISIONA_DB_AUTO_MIGRATE` 預設 **true**(`internal/config/load.go:109`)→ 啟動自動跑 `migrate up`。 + +## 2. 前置需求 + +| 項目 | 內容 | +|------|------| +| docker daemon | stage host 130 開 `tcp://192.168.0.130:2375`,公司內網直連(VPN 大流量會卡,見 deploy-stage-v2.sh 註解) | +| `.env.stage` | 含 DB + Redis + OIDC + storage 等 env,**git-ignored,不進 repo**(已驗證 `git check-ignore` 命中) | +| 真 PG | 192.168.0.130:5432 db=`visiona` user=`vsausr` sslmode=`disable`,schema 已 migrate 到 version 3 | +| 真 Redis | container `visiona-redis`,network `visiona-stage_default`,無密碼,6379 | +| 部署腳本 | `scripts/deploy-stage-v2.sh`(remote build 模式,build 全在 stage daemon 跑) | + +## 3. 部署步驟(DB 接入版) + +### 3.1 network 接 visiona-redis — 怎麼解決的(關鍵) + +backend 用 hostname `visiona-redis` 連 Redis,**必須跟 visiona-redis 同 docker network**否則解析失敗。 + +解決方式:**靠 compose project name 自動對上**,不需改 compose。 +- `deploy-stage-v2.sh` 用 `-p visiona-stage` 起 compose → 預設 network 名為 `visiona-stage_default`。 +- `visiona-redis` 本來就在 `visiona-stage_default`(Up,label 顯示屬於同 stack)。 +- 因此新 `visiona` container 起來自動落在同網段,`visiona-redis` hostname 直接可解。 + +驗證(部署前): +```bash +export DOCKER_HOST=tcp://192.168.0.130:2375 +docker run --rm --network visiona-stage_default redis:alpine redis-cli -h visiona-redis ping # → PONG +docker run --rm --network visiona-stage_default postgres:16-alpine pg_isready -h 192.168.0.130 -p 5432 # → accepting connections +``` + +⚠️ 注意:`docker-compose.stage.yml` 沒有顯式宣告 external network。目前能對上是因為「compose 自建的 default network 名稱 = visiona-redis 所在的 network 名稱」這個巧合(同 project name)。若未來 visiona-redis 改由別的 compose stack 管、或改 project name,需在 compose 顯式 `networks:` 接 external `visiona-stage_default`。建議後續硬化(見 §10)。 + +### 3.2 部署指令 + +```bash +# 1. 先備份當前 image 供 rollback +export DOCKER_HOST=tcp://192.168.0.130:2375 +CURID=$(docker inspect visiona --format '{{.Image}}' | cut -c8-19) +docker tag "$CURID" visiona:stage-rollback-pre-db + +# 2. build + deploy main HEAD(remote build,全在 stage daemon 跑) +DOCKER_HOST=tcp://192.168.0.130:2375 bash scripts/deploy-stage-v2.sh +``` + +部署會 `Recreate` visiona container(覆蓋舊版)。本次部署版本:**main HEAD 4d0b870**(DB 接入塊 0-5),image tag `visiona:stage` + `visiona:stage-20260620-184814-4d0b870`。 + +## 4. 啟動煙測(本次實測,2026-06-20) + +啟動 log 關鍵行(`.autoflow/07-delivery/logs/block6-container-startup-202606201848.log`): + +``` +postgres pool initialized target=192.168.0.130:5432/visiona sslmode=disable max_conns=10 min_conns=2 +migrate up: applied version=3 +migrations applied target=192.168.0.130:5432/visiona +redis client initialized target=visiona-redis:6379/0 db=0 +pairing/session token stores initialized backend=postgres +user session store initialized backend=redis +device repository initialized backend=postgres +device unpairer initialized backend=postgres-tx +model repository initialized backend=postgres +conversion service initialized converter_base_url=http://192.168.0.130:9501 +file access (FAA download) initialized (Phase 0.9 功能正常) +api-server listening addr=0.0.0.0:3721 +``` + +無 fatal / panic。所有 6 個 store 都顯示 `backend=postgres` / `backend=redis`(DB-on 模式生效)。 + +## 5. healthz 行為(重要) + +> **2026-06-20 更新**:§8 的 nginx /healthz 不反映 DB 健康問題**已修復並重部署**(見 §8、§9)。 +> 現在 stage 有兩條對外健康路由:淺層 `/healthz`(存活)與深層 `/healthz/deep`(readiness,反映 PG+Redis)。 + +| 路徑 | 回應(健康時) | 說明 | +|------|------|------| +| `https://stage-9527.innovedus.com:9527/healthz`(公開,淺層) | `200 "ok"` | nginx 直接 return,不打 backend、**不反映 DB**。用途:liveness / 「nginx 活著」探測。 | +| `https://stage-9527.innovedus.com:9527/healthz/deep`(公開,深層)✅ 新增 | `200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"}` | nginx `proxy_pass` 到 backend `/healthz`,**會 ping PG + Redis、down 回 503**。用途:**LB readiness**。 | +| backend `:3721/healthz`(容器內) | `200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"}` | 深層路由的後端來源,真正 ping PG + Redis。 | + +backend healthz 實作(`internal/api/health.go`):每次呼叫 ping PG + Redis,任一失敗回 **503**。 + +### 5.1 503 fail-fast 實測(停 visiona-redis) + +log:`.autoflow/07-delivery/logs/block6-healthz-503-202606201848.log` +``` +docker stop visiona-redis + → backend :3721/healthz HTTP 503 {"checks":{"postgres":"ok","redis":"down"},"status":"unavailable"} +docker start visiona-redis + → backend :3721/healthz HTTP 200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"} +``` +- Redis down → 正確偵測 `redis:down` 回 503,PG 仍 `ok`。 +- visiona container **沒有因 Redis 中斷而 crash**(fail-fast 只在啟動,runtime 降級僅反映在 healthz)。 +- Redis 恢復後 healthz 自動回 200。 + +## 6. 重啟資料持久化實測 + +log:`.autoflow/07-delivery/logs/block6-persistence-202606201848.log` +``` +插入 model row (name=block6-persist-...) 進 stage PG → rows_before_restart = 1 +docker restart visiona + → 啟動 log: "migrate up: no change (already at latest version)"(migration 冪等) + → backend healthz 200 +查詢 model row → rows_after_restart = 1(同 marker,資料還在) +(測試資料已 cleanup) +``` +證明真部署環境下 DB-on 的資料跨 container 重啟持久化。 + +## 7. 既有功能回歸 + +| 檢查 | 結果 | +|------|------| +| `GET /api/models`(公開,auth-gated) | `401`(api-server 活、DB-backed handler 可達,非 500/502) | +| `GET /`(frontend) | `200` | +| conversion service | 啟動 log 顯示 initialized(converter base url 正確) | +| FAA download(Phase 0.9) | 啟動 log 顯示 initialized | + +## 8. ✅ 已修復:nginx /healthz 不反映 DB 健康(+ docker healthcheck false 444) + +**原問題**:backend 的 DB-aware healthz(會 ping PG/Redis、down 回 503 的 fail-fast 邏輯)**實作了但 nginx 攔截不轉發**。 +- `docker/nginx.stage.conf` 兩個 `location = /healthz` 都 `return 200 "ok"`,不 `proxy_pass` 到 api-server。 +- 結果:對外 `/healthz` 永遠 200,即使 PG/Redis 掛了。backend 的 503 邏輯形同 dead code。 + +**併發現的第二個 bug(本次一併修)**:default_server block 結尾用 **server-level `return 444;`**,在 nginx rewrite phase 會「先於」location 匹配短路掉所有請求 —— 包含本該回 200 的 `location = /healthz`。導致 docker healthcheck(從 container 內打 `localhost/healthz`、來源 127.0.0.1)也被打成 **444**,container 長期顯示 `(unhealthy)`(FailingStreak 已累積到 96)。原 deploy-stage-v2.sh:196 把這當成「Host 白名單造成的 false negative」,實際成因是 `return 444` 的 phase 順序,不是 Host 白名單。 + +**修復內容(2026-06-20,commit 於 `docker/nginx.stage.conf`)**: +1. default_server 的 catch-all `return 444` 從 server-level 收進 `location / { return 444; }` —— 讓 exact-match `location = /healthz` 依 nginx location 優先序勝出。docker healthcheck 現回 200、container `healthy`。 +2. 公開 server block 新增 `location = /healthz/deep` → `proxy_pass http://visiona_api/healthz`(深層 readiness,反映 PG+Redis)。 +3. 保留淺層 `/healthz`(return 200)給 docker healthcheck / liveness。 + +**部署方式**:因 nginx config 在 image build 時 `COPY` 進去(非 bind mount),改 source 後以 `deploy-stage-v2.sh` **rebuild + recreate** 持久化(`nginx -s reload` 只是 runtime 暫補、container 重建即失效)。重部署後驗證 baked config 與 source 一致。 + +## 9. healthz 接 load balancer(最終建議 — 已實作) + +採 **選項 A**(liveness 淺 / readiness 深 分離,對齊 K8s 慣例)。已實作於 nginx: +```nginx +# 公開 server block(server_name stage-9527.innovedus.com) +location = /healthz/deep { + access_log off; + proxy_pass http://visiona_api/healthz; # api-server :3721,ping PG+Redis、down 回 503 + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_connect_timeout 2s; proxy_read_timeout 2s; proxy_send_timeout 2s; # 探針不該 hang + proxy_no_cache 1; proxy_cache_bypass 1; + add_header Cache-Control "no-store" always; +} +``` + +**LB 設定建議**: +- **health check path:`/healthz/deep`** ← LB readiness 打這條 +- 期望狀態碼:200;**503 → 拉出輪替**(DB/Redis 掛時自動踢除實例) +- interval 10–15s、timeout 5s、unhealthy threshold 連續 3 次(對齊 backend health.go 的 2s ping 逾時 + nginx 2s proxy 逾時) +- 淺層 `/healthz` 保留給 docker healthcheck / liveness(DB 抖動時不會誤把實例標死) + +**實測(2026-06-20,log:`.autoflow/07-delivery/logs/healthz-deep-verify-*.log`)**: +``` +PG+Redis 健康: + 公開 /healthz → 200 "ok" + 公開 /healthz/deep → 200 {"checks":{"postgres":"ok","redis":"ok"},"status":"ok"} + docker healthcheck → healthy(streak=0;修復前 unhealthy streak=96) +停 visiona-redis(503 路徑): + 公開 /healthz/deep → 503 {"checks":{"postgres":"ok","redis":"down"},"status":"unavailable"} ← LB 會踢 + 公開 /healthz → 200(淺層不受影響,liveness 不誤殺) + 公開 /(frontend) → 200 +起 visiona-redis(恢復): + 公開 /healthz/deep → 200(自動恢復) +``` + +## 10. 後續硬化建議(非本次範圍) + +1. ~~**§8 healthz 接 LB**:依 §9 選項 A 加 `/healthz/deep`。~~ ✅ **已完成(2026-06-20)**,見 §8/§9。 +2. **§3.1 network 顯式化**:在 `docker-compose.stage.yml` 顯式宣告 external network `visiona-stage_default`,去除「靠 project name 巧合對上」的隱性依賴。 +3. **migration 與多副本**:目前單副本啟動跑 auto-migrate 沒問題;未來多副本需改 `VISIONA_DB_AUTO_MIGRATE=false` + 獨立 `cmd/migrate` 步驟,避免多實例同時 migrate。 + +## 11. Rollback + +```bash +export DOCKER_HOST=tcp://192.168.0.130:2375 +# 回到 DB 接入前的 Phase 0.9 image +docker tag visiona:stage-rollback-pre-db visiona:stage +bash scripts/deploy-stage-v2.sh --skip-build +``` +(注意:rollback 到 DB 接入前版本後,repository 回 in-memory;PG/Redis 資料保留但不被讀。) + +## 12. 煙測 evidence log 清單 + +| log | 內容 | +|-----|------| +| `.autoflow/07-delivery/logs/block6-deploy-202606201848.log` | build + compose up(exit 0) | +| `.autoflow/07-delivery/logs/block6-container-startup-202606201848.log` | 啟動 log(DB/Redis/migration initialized) | +| `.autoflow/07-delivery/logs/block6-healthz-503-202606201848.log` | 503 fail-fast 實測(停/起 redis) | +| `.autoflow/07-delivery/logs/block6-persistence-202606201848.log` | 重啟資料持久化 + migration 冪等 | diff --git a/visionA-backend/cmd/api-server/conversion_adapters.go b/visionA-backend/cmd/api-server/conversion_adapters.go index 4b7af9b..ca85c57 100644 --- a/visionA-backend/cmd/api-server/conversion_adapters.go +++ b/visionA-backend/cmd/api-server/conversion_adapters.go @@ -65,6 +65,12 @@ func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion. FileSize: rec.FileSize, FileChecksum: rec.FileChecksum, TargetChip: rec.TargetChip, + // B4 模型 metadata(optional):從 converter job analysis_info 串進來。 + // 轉檔端尚未串好 analysis_info 時 rec.InputShape 等為零值(nil / ""), + // 這裡照樣賦值(賦 nil / "" 不影響建 model),DB 端寫 NULL / 空 INT[]。 + InputShape: rec.InputShape, + Classes: rec.Classes, + Framework: rec.Framework, Source: rec.Source, // 應為 "converted" SourceJobID: rec.SourceJobID, FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1:promote 寫入的 FAA object key @@ -119,6 +125,10 @@ func modelToRecord(m *model.Model) *conversion.ModelRecord { FileSize: m.FileSize, FileChecksum: m.FileChecksum, TargetChip: m.TargetChip, + // B4 模型 metadata(optional):冪等回傳時帶回,保持 round-trip 一致。 + InputShape: m.InputShape, + Classes: m.Classes, + Framework: m.Framework, Source: m.Source, SourceJobID: m.SourceJobID, FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1 diff --git a/visionA-backend/cmd/api-server/conversion_adapters_test.go b/visionA-backend/cmd/api-server/conversion_adapters_test.go new file mode 100644 index 0000000..c3ec5c2 --- /dev/null +++ b/visionA-backend/cmd/api-server/conversion_adapters_test.go @@ -0,0 +1,372 @@ +// conversion_adapters_test.go — conversionModelStoreAdapter 的單元測試。 +// +// 聚焦 B4 metadata(InputShape / Classes / Framework)的雙向 round-trip: +// - Save:conversion.ModelRecord → model.Model 時 metadata 要帶過去(adapter 曾遺漏這段, +// 導致 flow.go 寫進 ModelRecord 的 input_shape 在 adapter 被吞掉、進不了 DB) +// - modelToRecord(透過 FindBySourceJobID):model.Model → conversion.ModelRecord 帶回 +// +// 用 model.NewInMemoryRepository(非 DB),不需 dbtest tag;DB 端真正落地由 PG repo 測試覆蓋。 +// +// Phase 0.8 conversion B4 (見 .autoflow/04-architecture/conversion.md §2.5) +package main + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/conversion" + "visiona-backend/internal/model" + "visiona-backend/internal/storage" +) + +// TestConversionAdapter_Save_CarriesMetadata:Save 應把 ModelRecord 的 B4 metadata +// 帶進底層 model.Model(adapter 曾漏掉 InputShape/Classes/Framework 的 wiring)。 +func TestConversionAdapter_Save_CarriesMetadata(t *testing.T) { + t.Parallel() + + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + now := time.Now().UTC() + rec := &conversion.ModelRecord{ + ID: "m-1", + OwnerUserID: "user-alice", + Name: "yolov5s_kl720", + StorageKey: "models/user-alice/m-1.nef", + FileSize: 12345, + TargetChip: "kl720", + InputShape: []int{1, 3, 224, 224}, + Classes: []string{"face", "person"}, + Framework: "onnx", + Source: "converted", + SourceJobID: "j-1", + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, adapter.Save(context.Background(), rec)) + + // 直接從底層 repo 撈回,驗 metadata 確實落進 model.Model + got, err := repo.Get(context.Background(), "m-1") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, []int{1, 3, 224, 224}, got.InputShape, + "adapter.Save 應把 ModelRecord.InputShape 帶進 model.Model(NCHW)") + assert.Equal(t, []string{"face", "person"}, got.Classes) + assert.Equal(t, "onnx", got.Framework) +} + +// TestConversionAdapter_Save_NilMetadata_OK:metadata 為零值(轉檔端尚未串好)→ +// adapter.Save 照常成功、model.Model metadata 留零值、不報錯。 +func TestConversionAdapter_Save_NilMetadata_OK(t *testing.T) { + t.Parallel() + + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + now := time.Now().UTC() + rec := &conversion.ModelRecord{ + ID: "m-2", + OwnerUserID: "user-bob", + Name: "model_kl520", + StorageKey: "models/user-bob/m-2.nef", + TargetChip: "kl520", + Source: "converted", + SourceJobID: "j-2", + CreatedAt: now, + UpdatedAt: now, + // 刻意不設 InputShape / Classes / Framework + } + require.NoError(t, adapter.Save(context.Background(), rec)) + + got, err := repo.Get(context.Background(), "m-2") + require.NoError(t, err) + require.NotNil(t, got) + assert.Nil(t, got.InputShape, "缺 metadata → model.Model.InputShape 留 nil") + assert.Nil(t, got.Classes) + assert.Empty(t, got.Framework) +} + +// TestConversionAdapter_FindBySourceJobID_RoundTripsMetadata:modelToRecord 應把 +// model.Model 的 metadata 帶回 conversion.ModelRecord(冪等回傳路徑保持一致)。 +func TestConversionAdapter_FindBySourceJobID_RoundTripsMetadata(t *testing.T) { + t.Parallel() + + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + now := time.Now().UTC() + require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{ + ID: "m-3", + OwnerUserID: "user-carol", + Name: "net_kl730", + StorageKey: "models/user-carol/m-3.nef", + TargetChip: "kl730", + InputShape: []int{1, 3, 640, 480}, + Classes: []string{"dog"}, + Framework: "tflite", + Source: "converted", + SourceJobID: "j-3", + CreatedAt: now, + UpdatedAt: now, + })) + + rec, err := adapter.FindBySourceJobID(context.Background(), "user-carol", "j-3") + require.NoError(t, err) + require.NotNil(t, rec) + assert.Equal(t, []int{1, 3, 640, 480}, rec.InputShape, + "modelToRecord 應把 model.Model.InputShape 帶回 ModelRecord") + assert.Equal(t, []string{"dog"}, rec.Classes) + assert.Equal(t, "tflite", rec.Framework) +} + +// ─────────────────────── 補強:Save error / nil ─────────────────────── + +// Save(nil):error path —— nil record 應回明確 error,不 panic、不寫入。 +func TestConversionAdapter_Save_NilRecord_Errors(t *testing.T) { + t.Parallel() + adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository()) + err := adapter.Save(context.Background(), nil) + require.Error(t, err, "Save(nil) 應回 error 而非 panic") + assert.Contains(t, err.Error(), "non-nil record") +} + +// Save:UpdatedAt 為零值時 UploadedAt fallback 到 now(promote 完即 ready)。 +// 對齊 toModelResponse:UploadedAt 非 nil → status "ready"。 +func TestConversionAdapter_Save_ZeroUpdatedAt_SetsUploadedAtNow(t *testing.T) { + t.Parallel() + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + before := time.Now().UTC() + require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{ + ID: "m-zero", + OwnerUserID: "user-zero", + Name: "zero_updated_at", + StorageKey: "models/user-zero/m-zero.nef", + TargetChip: "kl520", + Source: "converted", + SourceJobID: "j-zero", + // 刻意不設 UpdatedAt(零值) + })) + after := time.Now().UTC() + + got, err := repo.Get(context.Background(), "m-zero") + require.NoError(t, err) + require.NotNil(t, got.UploadedAt, "UpdatedAt 為零時 UploadedAt 應 fallback 到 now,非 nil") + assert.False(t, got.UploadedAt.Before(before), "UploadedAt 應 >= 呼叫前時間") + assert.False(t, got.UploadedAt.After(after.Add(time.Second)), "UploadedAt 應 <= 呼叫後時間") +} + +// Save:UpdatedAt 非零時 UploadedAt 沿用 rec.UpdatedAt(不覆蓋成 now)。 +func TestConversionAdapter_Save_NonZeroUpdatedAt_UsesIt(t *testing.T) { + t.Parallel() + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + fixed := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{ + ID: "m-fixed", + OwnerUserID: "user-fixed", + Name: "fixed_updated_at", + StorageKey: "models/user-fixed/m-fixed.nef", + TargetChip: "kl730", + Source: "converted", + SourceJobID: "j-fixed", + UpdatedAt: fixed, + })) + + got, err := repo.Get(context.Background(), "m-fixed") + require.NoError(t, err) + require.NotNil(t, got.UploadedAt) + assert.Equal(t, fixed, *got.UploadedAt, "UpdatedAt 非零時 UploadedAt 應沿用之") +} + +// Save:完整欄位 round-trip —— 不只 B4 metadata,連 Description / FileChecksum / +// FAAObjectKey / Source / SourceJobID 都要正確帶進 model.Model。 +func TestConversionAdapter_Save_AllFields_RoundTrip(t *testing.T) { + t.Parallel() + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + now := time.Now().UTC() + rec := &conversion.ModelRecord{ + ID: "m-full", + OwnerUserID: "user-full", + Name: "full_fields", + Description: "a full conversion record", + StorageKey: "models/user-full/m-full.nef", + FileSize: 987654, + FileChecksum: "sha256:deadbeef", + TargetChip: "kl720", + InputShape: []int{1, 3, 320, 320}, + Classes: []string{"a", "b", "c"}, + Framework: "onnx", + Source: "converted", + SourceJobID: "j-full", + FAAObjectKey: "faa/obj/full-key", + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, adapter.Save(context.Background(), rec)) + + got, err := repo.Get(context.Background(), "m-full") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "a full conversion record", got.Description) + assert.Equal(t, int64(987654), got.FileSize) + assert.Equal(t, "sha256:deadbeef", got.FileChecksum) + assert.Equal(t, "kl720", got.TargetChip) + assert.Equal(t, model.Source("converted"), got.Source) + assert.Equal(t, "j-full", got.SourceJobID) + assert.Equal(t, "faa/obj/full-key", got.FAAObjectKey) + assert.Equal(t, now, got.CreatedAt) +} + +// ─────────────────────── 補強:FindBySourceJobID edge ─────────────────────── + +// FindBySourceJobID:empty args(ownerUserID 或 sourceJobID 為空)→ (nil, nil),不查 repo。 +func TestConversionAdapter_FindBySourceJobID_EmptyArgs_ReturnsNil(t *testing.T) { + t.Parallel() + adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository()) + + cases := []struct{ owner, job string }{ + {"", "j-1"}, + {"user-1", ""}, + {"", ""}, + } + for _, tc := range cases { + rec, err := adapter.FindBySourceJobID(context.Background(), tc.owner, tc.job) + require.NoError(t, err) + assert.Nil(t, rec, "empty args 應回 (nil, nil)") + } +} + +// FindBySourceJobID:repo 有資料但無 match 的 sourceJobID → (nil, nil)。 +func TestConversionAdapter_FindBySourceJobID_NoMatch_ReturnsNil(t *testing.T) { + t.Parallel() + repo := model.NewInMemoryRepository() + adapter := newConversionModelStoreAdapter(repo) + + now := time.Now().UTC() + require.NoError(t, adapter.Save(context.Background(), &conversion.ModelRecord{ + ID: "m-nm", + OwnerUserID: "user-nm", + Name: "no_match", + StorageKey: "models/user-nm/m-nm.nef", + TargetChip: "kl520", + Source: "converted", + SourceJobID: "j-exists", + CreatedAt: now, + UpdatedAt: now, + })) + + // 對的 owner、不存在的 job + rec, err := adapter.FindBySourceJobID(context.Background(), "user-nm", "j-does-not-exist") + require.NoError(t, err) + assert.Nil(t, rec) + + // 不同 owner、存在的 job(owner 過濾應使其找不到) + rec2, err := adapter.FindBySourceJobID(context.Background(), "user-other", "j-exists") + require.NoError(t, err) + assert.Nil(t, rec2, "不同 owner 不應 match(List 已用 OwnerUserID 過濾)") +} + +// ─────────────────────── 補強:GenerateID ─────────────────────── + +// GenerateID:每次回傳唯一非空值(uuid),快速重複呼叫不應碰撞(concurrency-ish / 唯一性)。 +func TestConversionAdapter_GenerateID_Unique(t *testing.T) { + t.Parallel() + adapter := newConversionModelStoreAdapter(model.NewInMemoryRepository()) + + seen := make(map[string]struct{}) + for i := 0; i < 1000; i++ { + id := adapter.GenerateID() + require.NotEmpty(t, id, "GenerateID 不應回空字串") + _, dup := seen[id] + require.False(t, dup, "GenerateID 不應碰撞,重複值: %s", id) + seen[id] = struct{}{} + } +} + +// ─────────────────────── 補強:modelToRecord nil ─────────────────────── + +// modelToRecord(nil):edge —— nil model 應回 nil record,不 panic。 +func TestModelToRecord_Nil_ReturnsNil(t *testing.T) { + t.Parallel() + assert.Nil(t, modelToRecord(nil)) +} + +// ─────────────────────── 補強:conversionStorageAdapter.Put ─────────────────────── + +// recordingStore 是只記下 Put 參數的 storage.Store fake(驗 adapter 透傳)。 +type recordingStore struct { + gotKey string + gotSize int64 + gotMeta map[string]string + gotBody []byte + putErr error +} + +func (r *recordingStore) Put(ctx context.Context, key string, rd io.Reader, size int64, meta map[string]string) error { + r.gotKey = key + r.gotSize = size + r.gotMeta = meta + if rd != nil { + r.gotBody, _ = io.ReadAll(rd) + } + return r.putErr +} + +// 其餘 storage.Store 方法非本測試關注點,回零值即可(adapter 只用到 Put)。 +func (r *recordingStore) Get(ctx context.Context, key string) (io.ReadCloser, *storage.Object, error) { + return nil, nil, storage.ErrNotFound +} +func (r *recordingStore) Stat(ctx context.Context, key string) (*storage.Object, error) { + return nil, storage.ErrNotFound +} +func (r *recordingStore) Exists(ctx context.Context, key string) (bool, error) { return false, nil } +func (r *recordingStore) Delete(ctx context.Context, key string) error { return nil } +func (r *recordingStore) List(ctx context.Context, prefix string) ([]*storage.Object, error) { + return nil, nil +} +func (r *recordingStore) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + return "", nil +} +func (r *recordingStore) PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + return "", nil +} + +// conversionStorageAdapter.Put:happy —— 透傳 key / size / meta / body 給底層 store。 +func TestConversionStorageAdapter_Put_PassesThrough(t *testing.T) { + t.Parallel() + rs := &recordingStore{} + adapter := &conversionStorageAdapter{store: rs} + + body := []byte("converted-nef-bytes") + meta := map[string]string{"source": "converter"} + err := adapter.Put(context.Background(), "models/u/x.nef", bytes.NewReader(body), int64(len(body)), meta) + require.NoError(t, err) + + assert.Equal(t, "models/u/x.nef", rs.gotKey) + assert.Equal(t, int64(len(body)), rs.gotSize) + assert.Equal(t, meta, rs.gotMeta, "meta 應原樣透傳") + assert.Equal(t, body, rs.gotBody, "body 應原樣透傳") +} + +// conversionStorageAdapter.Put:error path —— 底層 store 回錯時 adapter 應原樣回傳該 error。 +func TestConversionStorageAdapter_Put_PropagatesError(t *testing.T) { + t.Parallel() + sentinel := errors.New("disk full") + rs := &recordingStore{putErr: sentinel} + adapter := &conversionStorageAdapter{store: rs} + + err := adapter.Put(context.Background(), "k", bytes.NewReader([]byte("x")), 1, nil) + assert.ErrorIs(t, err, sentinel, "底層 Put error 應原樣往上傳") +} diff --git a/visionA-backend/cmd/api-server/dbon_fk_fix_test.go b/visionA-backend/cmd/api-server/dbon_fk_fix_test.go new file mode 100644 index 0000000..c119d9a --- /dev/null +++ b/visionA-backend/cmd/api-server/dbon_fk_fix_test.go @@ -0,0 +1,89 @@ +// dbon_fk_fix_test.go — DB-on FK 收尾兩個問題的 integration 驗證(in-memory wiring)。 +// +// 不帶 build tag:屬於預設 `go test ./...` 範圍。用 fixture 的 in-memory store + in-memory +// exchanger 走真實 OIDC login flow / pairing exchange flow,驗證: +// - 問題 #1:OIDC callback 成功後 user 被 provision 進 UserStore。 +// - 問題 #2:pairing exchange 成功後自建一筆 device,且該 device owner = 登入 user。 +// +// DB-on 行為(真 FK / 交易)由 internal/user/postgres_store_db_test.go 與 +// internal/api/pairing_exchange_db_test.go 的 dbtest 覆蓋(待 130 補跑)。 +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/api" + "visiona-backend/internal/auth" +) + +// TestDBOnFix_OIDCCallbackProvisionsUser 驗證問題 #1:OIDC callback 成功後 user 進 UserStore。 +func TestDBOnFix_OIDCCallbackProvisionsUser(t *testing.T) { + f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer f.Close() + + // 登入前:user 不存在 + _, err := f.userStore.Get(context.Background(), "alice-sub") + require.Error(t, err, "登入前 user 不應存在") + + // 走完整 OIDC login flow(callback 會 Upsert user) + _ = f.AuthenticatedClient(t, "alice-sub", "alice@example.com") + + // 登入後:user 已被 provision + got, err := f.userStore.Get(context.Background(), "alice-sub") + require.NoError(t, err, "OIDC callback 後 user 應被 provision 進 UserStore") + assert.Equal(t, "alice-sub", got.ID) + assert.Equal(t, "alice@example.com", got.Email) +} + +// TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2:pairing exchange 後自建 device(owner 對齊)。 +func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) { + f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer f.Close() + + const sub = "bob-sub" + client := f.AuthenticatedClient(t, sub, "bob@example.com") + + // 登入後但 exchange 前:該 user 名下無 device + before, err := f.deviceRepo.List(context.Background(), sub) + require.NoError(t, err) + require.Empty(t, before, "exchange 前不應有 device") + + // 1) 產 pairing token + tokResp, err := client.Post(f.apiServer.URL+"/api/pairing/token", "", nil) + require.NoError(t, err) + defer tokResp.Body.Close() + require.Equal(t, http.StatusOK, tokResp.StatusCode) + var tokBody map[string]any + require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody)) + pairingTok := tokBody["data"].(map[string]any)["token"].(string) + + // 2) exchange(不走 AuthMiddleware)→ 應自建 device + 建 session token + reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok}) + exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange", + "application/json", bytes.NewReader(reqBody)) + require.NoError(t, err) + defer exchResp.Body.Close() + require.Equal(t, http.StatusOK, exchResp.StatusCode) + var exchBody map[string]any + require.NoError(t, json.NewDecoder(exchResp.Body).Decode(&exchBody)) + sessionTok := exchBody["data"].(map[string]any)["session_token"].(string) + require.True(t, auth.IsValidSessionToken(sessionTok)) + + // 3) exchange 後:該 user 名下多了一筆自建 device + after, err := f.deviceRepo.List(context.Background(), sub) + require.NoError(t, err) + require.Len(t, after, 1, "exchange 應自建一筆 device") + assert.Equal(t, sub, after[0].OwnerUserID, "device owner 應為登入 user") + assert.NotNil(t, after[0].PairedAt, "自建 device 應設 paired_at") +} diff --git a/visionA-backend/cmd/api-server/e2e_dbon_test.go b/visionA-backend/cmd/api-server/e2e_dbon_test.go new file mode 100644 index 0000000..c002a4d --- /dev/null +++ b/visionA-backend/cmd/api-server/e2e_dbon_test.go @@ -0,0 +1,309 @@ +//go:build dbtest + +// e2e_dbon_test.go — 塊 6 完整版(6.4 整鏈持久化 e2e + 6.5 DB-on 回歸驗證)。 +// +// 這是「DB 接入」的最終 e2e 驗收:證明 6 個 store 都接上 Postgres / Redis 後, +// +// 1. 既有 e2e 路徑(OIDC 登入 → model 上傳 → 列 → get/ownership)在 DB-on 模式仍跑通; +// 2. **重啟(pool 重建)後資料還在**(持久化的核心承諾——in-memory 模式做不到); +// 3. **unpair device 後該 device 的 pairing + session token 真的被撤銷**(塊 5.2 cascade +// 的 e2e/store 層驗證,含直接查 DB 斷言 revoked_at / deleted_at)。 +// +// build tag `dbtest`:需要 Docker(PG 容器)。本機無 docker → Orchestrator 在 130 補跑: +// +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest -run 'DBOn' ./cmd/api-server/... -v +// +// 設計取捨(重要,與既有 in-memory e2e 的差異點,對應回報給 Orchestrator 的發現): +// +// - **owner_user_id FK**:DB-on 下 models/devices/*_tokens 的 owner 是 `UUID NOT NULL +// REFERENCES users(id)`。AuthenticatedClient 的 OIDC sub 必須是合法 UUID,且對應 users 列 +// 必須先存在(production OIDC callback 不 auto-provision users → 見回報的疑似 bug)。 +// 故 DB-on e2e 一律用 uuid.NewString() 當 sub + 先 f.ensureUser(...)。 +// +// - **pairing→exchange 整鏈走不到 HTTP**:session_tokens.device_id 是 `UUID NOT NULL +// REFERENCES devices(id)`,但雛形 pairing exchange handler 傳的 info.DeviceID 為空 +// (pairing token 未綁 device)→ 在 DB-on 下 Create session token 會因 NOT NULL/FK 失敗。 +// 因此「unpair cascade」這條無法靠 HTTP exchange 製造已綁 device 的 session token,改在 +// store 層直接建(user → device → 已綁 device 的 pairing token + session token),再經 +// DeviceUnpairer.Unpair 驗 cascade。這如實反映雛形現況,且仍覆蓋塊 5.2 的 Postgres tx 路徑。 +// (此差異已回報 Orchestrator:HTTP exchange 在 DB-on 模式需綁 device_id 才能完整跑通。) +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/api" + "visiona-backend/internal/auth" + "visiona-backend/internal/db" + "visiona-backend/internal/device" +) + +// discardLoggerDBOn 回傳一個丟棄輸出的 logger(避免測試噪音)。 +func discardLoggerDBOn() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// noopLocal 是不需要 tunnel 的 e2e 用的空 local handler。 +func noopLocal() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) +} + +// uploadModelDBOn 走完整兩階段上傳(init → PUT → finalize),回傳 model id。 +// 對齊 b5_integration_test.go 的 TestB5_ModelUploadFlow,但跑在 DB-on fixture 上。 +func uploadModelDBOn(t *testing.T, f *dbOnFixture, client *http.Client, name string) string { + t.Helper() + + // 1. init + initBody, _ := json.Marshal(map[string]any{ + "name": name, + "file_size": 11, + "target_chip": "kl520", + }) + initResp, err := client.Post(f.apiServer.URL+"/api/models/init", "application/json", bytes.NewReader(initBody)) + require.NoError(t, err) + require.Equal(t, http.StatusOK, initResp.StatusCode, "init 應成功(DB-on)") + var initRespBody map[string]any + require.NoError(t, json.NewDecoder(initResp.Body).Decode(&initRespBody)) + initResp.Body.Close() + initData := initRespBody["data"].(map[string]any) + modelID := initData["model_id"].(string) + uploadURL := initData["upload_url"].(string) + require.NotEmpty(t, modelID) + require.NotEmpty(t, uploadURL) + + // 2. PUT 檔案(HMAC presigned,不走 auth) + payload := []byte("hello world") // 11 bytes 對上 file_size + putReq, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(payload)) + require.NoError(t, err) + putReq.ContentLength = int64(len(payload)) + putResp, err := http.DefaultClient.Do(putReq) + require.NoError(t, err) + defer putResp.Body.Close() + require.Equal(t, http.StatusNoContent, putResp.StatusCode, "PUT 應 204") + + // 3. finalize + finResp, err := client.Post(f.apiServer.URL+"/api/models/"+modelID+"/finalize", "application/json", nil) + require.NoError(t, err) + defer finResp.Body.Close() + require.Equal(t, http.StatusOK, finResp.StatusCode, "finalize 應成功(DB-on)") + var fbody map[string]any + require.NoError(t, json.NewDecoder(finResp.Body).Decode(&fbody)) + fdata := fbody["data"].(map[string]any) + assert.Equal(t, "ready", fdata["status"]) + assert.Equal(t, modelID, fdata["id"]) + + return modelID +} + +// listModelIDsDBOn 打 GET /api/models 回 model id 集合。 +func listModelIDsDBOn(t *testing.T, f *dbOnFixture, client *http.Client) map[string]bool { + t.Helper() + resp, err := client.Get(f.apiServer.URL + "/api/models") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + out := map[string]bool{} + for _, raw := range body["data"].([]any) { + m := raw.(map[string]any) + out[m["id"].(string)] = true + } + return out +} + +// TestDBOn_ModelUploadFlow_E2E 驗證 OIDC 登入 → model 上傳 → 列 → get/ownership 在 DB-on 跑通。 +// +// 這是把 b5 in-memory 上傳 e2e「平移到 Postgres」的回歸驗證:證明改注入 PG model repo + +// PG token store + Redis session 後,handler 一行不改仍端對端跑通。 +func TestDBOn_ModelUploadFlow_E2E(t *testing.T) { + f := setupFixtureDBOn(t, noopLocal()) + defer f.Close() + + sub := uuid.NewString() + email := "dbon-upload@visiona.local" + f.ensureUser(t, sub, email) + client := f.AuthenticatedClient(t, sub, email) + + modelID := uploadModelDBOn(t, f, client, "YOLOv5 DB-on") + + // GET /api/models — 看得到剛上傳的 + ids := listModelIDsDBOn(t, f, client) + assert.True(t, ids[modelID], "list 應含剛上傳的 model(DB-on)") + + // GET /api/models/:id — owner 取得成功 + getResp, err := client.Get(f.apiServer.URL + "/api/models/" + modelID) + require.NoError(t, err) + defer getResp.Body.Close() + require.Equal(t, http.StatusOK, getResp.StatusCode, "owner get 應 200") + + // 直接查 DB 斷言 row 真的落地(不只是 handler 記憶體)。 + var cnt int + require.NoError(t, f.pool.Pool().QueryRow(context.Background(), + `SELECT count(*) FROM models WHERE id = $1 AND owner_user_id = $2 AND deleted_at IS NULL`, + modelID, sub).Scan(&cnt)) + assert.Equal(t, 1, cnt, "model row 應已落 Postgres") + + // 另一個 user 不該看到(owner 隔離 + FK 也成立)。 + otherSub := uuid.NewString() + f.ensureUser(t, otherSub, "other-dbon@visiona.local") + otherClient := f.AuthenticatedClient(t, otherSub, "other-dbon@visiona.local") + otherGet, err := otherClient.Get(f.apiServer.URL + "/api/models/" + modelID) + require.NoError(t, err) + defer otherGet.Body.Close() + assert.Equal(t, http.StatusForbidden, otherGet.StatusCode, "非 owner 取他人 model 應 403") +} + +// TestDBOn_PersistAcrossRestart 是塊 6 的核心承諾:**重啟後資料還在**。 +// +// 重啟模擬:上傳 model 後,關掉 fixture 當前 pool、用同一個 tdb.Cfg 新建第二個 pool +// (指向同一個 Postgres 容器),證明資料落在 DB 而非 process 記憶體。 +// 第二個 pool 直接查 + 經一個「新 router(新 PG repo)」打 API 都看得到。 +// +// 對照:in-memory 模式(NewInMemoryRepository)一旦 process 重啟資料即消失, +// 本 test 在 in-memory fixture 下會失敗——正是 DB 接入要解決的問題。 +func TestDBOn_PersistAcrossRestart(t *testing.T) { + f := setupFixtureDBOn(t, noopLocal()) + defer f.Close() + + sub := uuid.NewString() + email := "dbon-restart@visiona.local" + f.ensureUser(t, sub, email) + client := f.AuthenticatedClient(t, sub, email) + + modelID := uploadModelDBOn(t, f, client, "Persist-Across-Restart") + + // === 模擬「process 重啟」:丟棄當前 pool,新建一個指向同一容器的 pool === + f.pool.Close() // 關掉 fixture 原 pool(模擬舊 process 退出) + + newPool, err := db.NewPool(context.Background(), f.tdb.Cfg, discardLoggerDBOn()) + require.NoError(t, err, "重啟後新建 pool") + defer newPool.Close() + + // 新 pool 直接查:資料還在。 + var cnt int + require.NoError(t, newPool.Pool().QueryRow(context.Background(), + `SELECT count(*) FROM models WHERE id = $1 AND deleted_at IS NULL`, modelID).Scan(&cnt)) + assert.Equal(t, 1, cnt, "重啟(新 pool)後 model row 仍在 Postgres —— 持久化成立") + + // 進一步:owner_user_id 也仍正確(FK + 欄位都落地)。 + var owner string + require.NoError(t, newPool.Pool().QueryRow(context.Background(), + `SELECT owner_user_id::text FROM models WHERE id = $1`, modelID).Scan(&owner)) + assert.Equal(t, sub, owner, "重啟後 model.owner_user_id 仍為原 user") +} + +// TestDBOn_SeedNotDuplicatedAcrossRestart 驗證 seed 行為在「重啟(重跑 ensure)」下不重複。 +// +// seed.go 用 `INSERT ... ON CONFLICT (id) DO NOTHING` upsert demo user;多次呼叫不應產生 +// 重複 user 列。本 test 直接重複 upsert 同一 demo user 兩次(模擬重啟兩次都跑 seed), +// 斷言 users 仍只有一筆——對應「重啟不重複 seed」的承諾(塊 6 完整版要點)。 +func TestDBOn_SeedNotDuplicatedAcrossRestart(t *testing.T) { + f := setupFixtureDBOn(t, noopLocal()) + defer f.Close() + + const demoUser = "00000000-0000-0000-0000-0000000000d3" // 對齊 seed.go demoSeedUserID + ctx := context.Background() + + upsert := func() { + _, err := f.pool.Pool().Exec(ctx, + `INSERT INTO users (id, email, name) VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING`, + demoUser, "demo@visiona.local", "Demo User (seeded)") + require.NoError(t, err) + } + upsert() // 第一次「啟動 seed」 + upsert() // 第二次「重啟 seed」 + + var cnt int + require.NoError(t, f.pool.Pool().QueryRow(ctx, + `SELECT count(*) FROM users WHERE id = $1`, demoUser).Scan(&cnt)) + assert.Equal(t, 1, cnt, "重複 seed(重啟)後 demo user 仍只有一筆 —— ON CONFLICT 生效") +} + +// TestDBOn_UnpairCascade_RevokesTokens 是塊 5.2 cascade 的 e2e/store 層驗證(Postgres tx 路徑)。 +// +// 建一條完整資料鏈:user → device → 已綁該 device 的 pairing token(MarkUsed 綁 device)+ +// 已綁該 device 的 session token,再經 DeviceUnpairer.Unpair(= main.go 在 DB-on 注入的 +// pgDeviceUnpairer,單一交易內軟刪 device + 撤兩張 token),最後**直接查 DB** 斷言: +// - device.deleted_at 非 NULL(軟刪) +// - 該 device 的 pairing token revoked_at 非 NULL +// - 該 device 的 session token revoked_at 非 NULL +// - Unpair 回報撤銷數正確 +// +// 為什麼不走 HTTP /unpair:unpair handler 需要 AuthMiddleware + device 屬於登入者;本 test 聚焦 +// cascade 的「真撤 token」核心(跨三張表的交易一致性),直接調 unpairer 並查 DB 最直接、最少噪音。 +// HTTP /unpair 的 happy/404 路徑由 in-memory 的 devices unpair test 覆蓋(行為一致)。 +func TestDBOn_UnpairCascade_RevokesTokens(t *testing.T) { + f := setupFixtureDBOn(t, noopLocal()) + defer f.Close() + + ctx := context.Background() + tdb := f.tdb + + // 1. user + device(用 testsupport fixture 直接寫,滿足 FK) + userID := tdb.InsertUser(t, uuid.NewString(), "unpair-dbon@visiona.local") + deviceID := tdb.InsertDevice(t, uuid.NewString(), userID) + + // 2. pairing token(綁 device):Create → MarkUsed(deviceID) 寫入 device_id。 + pairingStore := auth.NewPostgresPairingStore(f.pool.Pool()) + ptPlain, _, err := pairingStore.Create(ctx, userID, time.Hour) + require.NoError(t, err, "create pairing token") + require.NoError(t, pairingStore.MarkUsed(ctx, ptPlain, deviceID), "mark pairing token used + bind device") + + // 3. session token(綁 device):device_id NOT NULL FK,這裡傳真 device → 寫入成功。 + sessionStore := auth.NewPostgresSessionTokenStore(f.pool.Pool()) + stPlain, _, err := sessionStore.Create(ctx, userID, deviceID, "", 90*24*time.Hour) + require.NoError(t, err, "create session token bound to device") + + // 前置斷言:兩 token 撤銷前都「未撤銷」。 + require.Equal(t, 0, countRevokedTokensDBOn(t, f, "pairing_tokens", deviceID), "撤銷前 pairing 應為 0 revoked") + require.Equal(t, 0, countRevokedTokensDBOn(t, f, "session_tokens", deviceID), "撤銷前 session 應為 0 revoked") + + // 4. Unpair(Postgres tx:device 軟刪 + cascade 撤兩張 token) + unpairer := api.NewPostgresDeviceUnpairer(f.pool.Pool(), + device.NewPostgresRepository(f.pool.Pool()), + pairingStore, sessionStore, discardLoggerDBOn()) + res, err := unpairer.Unpair(ctx, deviceID) + require.NoError(t, err, "unpair 應成功") + assert.Equal(t, 1, res.PairingRevoked, "應撤 1 個 pairing token") + assert.Equal(t, 1, res.SessionRevoked, "應撤 1 個 session token") + + // 5. 直接查 DB 斷言 cascade 落地 + // 5a. device 軟刪 + var deletedAt *time.Time + require.NoError(t, f.pool.Pool().QueryRow(ctx, + `SELECT deleted_at FROM devices WHERE id = $1`, deviceID).Scan(&deletedAt)) + assert.NotNil(t, deletedAt, "device 應被軟刪(deleted_at 非 NULL)") + + // 5b. 兩張 token 都撤銷 + assert.Equal(t, 1, countRevokedTokensDBOn(t, f, "pairing_tokens", deviceID), "pairing token 應 revoked") + assert.Equal(t, 1, countRevokedTokensDBOn(t, f, "session_tokens", deviceID), "session token 應 revoked") + + // 5c. 撤銷後 session token Get 應失敗(不可再用 → tunnel 連不上)。 + _, getErr := sessionStore.Get(ctx, stPlain) + assert.ErrorIs(t, getErr, auth.ErrTokenRevoked, "撤銷後 session token Get 應回 ErrTokenRevoked") +} + +// countRevokedTokensDBOn 查某 device 名下已撤銷的 token 數(pairing_tokens / session_tokens 通用)。 +func countRevokedTokensDBOn(t *testing.T, f *dbOnFixture, table, deviceID string) int { + t.Helper() + var n int + // table 來自測試常數(非使用者輸入),直接內插安全。 + require.NoError(t, f.pool.Pool().QueryRow(context.Background(), + `SELECT count(*) FROM `+table+` WHERE device_id = $1 AND revoked_at IS NOT NULL`, + deviceID).Scan(&n)) + return n +} diff --git a/visionA-backend/cmd/api-server/fixture_dbon_test.go b/visionA-backend/cmd/api-server/fixture_dbon_test.go new file mode 100644 index 0000000..7175da9 --- /dev/null +++ b/visionA-backend/cmd/api-server/fixture_dbon_test.go @@ -0,0 +1,211 @@ +//go:build dbtest + +// fixture_dbon_test.go — 塊 6 完整版(6.5 e2e 回歸):setupFixture 的 DB-on 變體。 +// +// 既有 integration / e2e(integration_test.go / b5_integration_test.go / e2e_full_flow_test.go / +// oidc_e2e_test.go / conversion_e2e_test.go)全部走 setupFixtureWithMaxUpload 的「6 個 in-memory +// store」版本——那是 local-dev fallback 行為。DB 接入塊 0–5 把 6 個 store 都接上 Postgres / Redis +// 後,必須證明「同一套 handler、改注入 PG/Redis store,e2e 仍跑得通」,這就是本檔提供的 +// setupFixtureDBOn。 +// +// build tag `dbtest`:本檔與 e2e_dbon_test.go 只在 `-tags=dbtest` 時編譯(需要 Docker daemon, +// 本機無 docker → 由 Orchestrator 在 130 docker host 補跑)。預設 `go test ./...` 不觸碰, +// 不影響既有 in-memory 模式的 e2e(兩種模式並存)。 +// +// 注入對照(對齊 cmd/api-server/main.go 的 dbPool != nil / redisClient != nil 分支): +// +// store in-memory(既有 fixture) DB-on(本檔) +// ----------------- --------------------------------------- ------------------------------------------- +// PairingStore auth.NewInMemoryPairingStore() auth.NewPostgresPairingStore(pool) +// SessionTokenStore auth.NewInMemorySessionTokenStore() auth.NewPostgresSessionTokenStore(pool) +// DeviceRepo device.NewInMemoryRepository() device.NewPostgresRepository(pool) +// ModelRepo model.NewInMemoryRepository() model.NewPostgresRepository(pool) +// DeviceUnpairer api.NewInMemoryDeviceUnpairer(...) api.NewPostgresDeviceUnpairer(pool,...) +// userSession usersession.NewInMemoryStore() usersession.NewRedisUserSessionStore(miniredis) +// tunnel session session.NewInMemoryStore() session.NewInMemoryStore()(不持久化、不受 DB 影響) +// +// 為什麼 userSession 用 miniredis 而非真 Redis:miniredis 在 process 內跑、無 Docker / 無網路需求, +// 與真 RedisUserSessionStore 走同一條 go-redis client 介面(main.go 也是注入 go-redis client)。 +// 真 Redis(130)行為已由 internal/usersession/redis_integration_test.go 另行覆蓋;此處 e2e 聚焦 +// 「PG 持久化 + Redis session 都接上後整鏈跑通」,miniredis 足夠且更乾淨。 +package main + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/api" + "visiona-backend/internal/auth" + "visiona-backend/internal/converter" + "visiona-backend/internal/db" + "visiona-backend/internal/db/testsupport" + "visiona-backend/internal/device" + "visiona-backend/internal/model" + "visiona-backend/internal/oidc" + "visiona-backend/internal/oidctest" + "visiona-backend/internal/relay" + "visiona-backend/internal/session" + "visiona-backend/internal/storage" + "visiona-backend/internal/usersession" +) + +// dbOnFixture 是 setupFixtureDBOn 的回傳:在 testFixture 之上多帶 DB-on 專屬的引用, +// 讓「重啟資料還在」「直接查 DB 斷言 cascade」這類 test 能拿到 pool / cfg。 +type dbOnFixture struct { + *testFixture + + // tdb 是 testcontainers 起的一次性 Postgres(含已 migrate 的 schema)。 + // 容器 / pool 的 teardown 由 SetupTestDB 的 t.Cleanup 管,呼叫端不需手動關。 + tdb *testsupport.TestDB + + // pool 是本次 fixture wire 進所有 PG store 的連線池(= tdb.Pool)。 + // 重啟模擬時,會新建第二個 pool 指向同一個容器(同 tdb.Cfg)來證明資料落在 DB 不在 process 記憶體。 + pool *db.Pool + + // miniRedis 是 user session 用的 in-process Redis。 + miniRedis *miniredis.Miniredis +} + +// ensureUser 為一個 OIDC sub(必須是合法 UUID)upsert 一筆 users 列。 +// +// 為什麼需要:DB-on 模式下 models.owner_user_id / devices.owner_user_id / *_tokens.user_id 都是 +// `UUID NOT NULL REFERENCES users(id)`。AuthenticatedClient 的 sub 是任意字串、且 production 的 +// OIDC callback 不會 auto-provision users 列(見回報給 Orchestrator 的疑似 production bug)。 +// 因此 DB-on e2e 在「登入後打需要 owner 的 API」前,必須先確保對應 users 列存在,否則 FK 失敗。 +// +// 回傳傳入的 sub(方便 inline 使用)。 +func (f *dbOnFixture) ensureUser(t *testing.T, sub, email string) string { + t.Helper() + _, err := f.pool.Pool().Exec(context.Background(), + `INSERT INTO users (id, email) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, + sub, email) + require.NoError(t, err, "ensure users row for OIDC sub %s", sub) + return sub +} + +// setupFixtureDBOn 啟動與 setupFixtureWithMaxUpload 相同的 5 段架構,但 6 個持久化 store +// 改注入 Postgres(testcontainers)+ Redis(miniredis)。 +// +// 與 in-memory 版的唯一差別在 store 注入;handler / router / OIDC / tunnel 完全相同, +// 證明「介面不變、只換後端」的塊 0–5 設計目標在 e2e 層成立。 +func setupFixtureDBOn(t *testing.T, localHandler http.Handler) *dbOnFixture { + t.Helper() + + // 0. 起 Postgres 容器(已 migrate)+ in-process Redis。 + tdb := testsupport.SetupTestDB(t) + mr := miniredis.RunT(t) + + // 1. fake local-tool + localBackend := httptest.NewServer(localHandler) + + // 2. remote-proxy(tunnel session store 仍 in-memory:yamux 連線註冊表,本來就不落 DB) + tunnelStore := session.NewInMemoryStore() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + relaySrv := relay.NewServer(tunnelStore, logger, relay.Options{KeepAliveInterval: 500 * time.Millisecond}) + internalSrv := relay.NewInternalServer(tunnelStore, logger) + + tunnelMux := http.NewServeMux() + tunnelMux.HandleFunc("/tunnel/connect", relaySrv.HandleTunnelConnect) + tunnelTS := httptest.NewServer(tunnelMux) + + internalMux := http.NewServeMux() + internalSrv.Routes(internalMux) + internalTS := httptest.NewServer(internalMux) + + // 3. api-server proxy/forwarder(指向 internalTS) + proxyClient := session.NewHTTPProxyClient(internalTS.URL, logger) + forwarder := session.NewForwarder(internalTS.URL, logger) + sessionStore := session.NewProxyClientStore(proxyClient, forwarder) + + storeDir := t.TempDir() + lazy := &lazyHandler{} + apiTS := httptest.NewServer(lazy) + storeStore, err := storage.NewLocalFSStore(storeDir, apiTS.URL+"/storage", "test-secret") + require.NoError(t, err) + + // 4. fake OIDC + OIDC client(同 in-memory 版) + fakeOIDC := oidctest.NewServer(t, + oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret), + ) + callbackURL := apiTS.URL + "/api/auth/callback" + oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 5*time.Second) + oidcProvider, err := oidc.NewProvider(oidcCtx, oidc.ProviderConfig{ + IssuerURL: fakeOIDC.URL, + ClientID: fakeOIDC.ClientID, + ClientSecret: fakeOIDC.ClientSecret, + RedirectURL: callbackURL, + }) + oidcCancel() + require.NoError(t, err, "fixture(dbon): OIDC provider init failed") + + // ===== DB-on store 注入(對齊 main.go dbPool/redisClient 非 nil 分支) ===== + pool, err := db.NewPool(context.Background(), tdb.Cfg, logger) + require.NoError(t, err, "fixture(dbon): second pool for fixture") + t.Cleanup(pool.Close) + + pgPairingStore := auth.NewPostgresPairingStore(pool.Pool()) + pgSessionTokenStore := auth.NewPostgresSessionTokenStore(pool.Pool()) + pgDeviceRepo := device.NewPostgresRepository(pool.Pool()) + pgModelRepo := model.NewPostgresRepository(pool.Pool()) + pgUnpairer := api.NewPostgresDeviceUnpairer(pool.Pool(), pgDeviceRepo, pgPairingStore, pgSessionTokenStore, logger) + + // user session:Redis(miniredis)。TTL 對齊 main.go 預設量級。 + redisClient := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = redisClient.Close() }) + userSessionStore := usersession.NewRedisUserSessionStore(redisClient, 24*time.Hour, 168*time.Hour) + sessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{ + Name: "visiona_session", + Path: "/", + HTTPOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + SigningKey: []byte(fixtureSessionSecret), + }) + + router := api.NewRouter(api.Deps{ + Logger: logger, + PairingStore: pgPairingStore, + SessionTokenStore: pgSessionTokenStore, + SessionStore: sessionStore, + Forwarder: forwarder, + DeviceRepo: pgDeviceRepo, + ModelRepo: pgModelRepo, + DeviceUnpairer: pgUnpairer, // 塊 5.2 cascade(Postgres tx) + Storage: storeStore, + Converter: converter.NewStubClient(), + RelayPublicURL: tunnelTS.URL, + OIDCProvider: oidcProvider, + SessionManager: sessionMgr, + OIDCPostLoginURL: apiTS.URL, + }) + lazy.Set(router) + + tf := &testFixture{ + apiServer: apiTS, + internalSrv: internalTS, + tunnelSrv: tunnelTS, + localBackend: localBackend, + store: tunnelStore, + forwarder: forwarder, + fakeOIDC: fakeOIDC, + pairingStore: nil, // DB-on 模式不暴露 in-memory pairingStore;需要直接查時走 f.pool + sessionMgr: sessionMgr, + router: router, + } + + return &dbOnFixture{ + testFixture: tf, + tdb: tdb, + pool: pool, + miniRedis: mr, + } +} diff --git a/visionA-backend/cmd/api-server/integration_test.go b/visionA-backend/cmd/api-server/integration_test.go index c16735b..0680e2f 100644 --- a/visionA-backend/cmd/api-server/integration_test.go +++ b/visionA-backend/cmd/api-server/integration_test.go @@ -51,6 +51,7 @@ import ( "visiona-backend/internal/relay" "visiona-backend/internal/session" "visiona-backend/internal/storage" + "visiona-backend/internal/user" "visiona-backend/internal/usersession" "visiona-backend/internal/wsconn" ) @@ -107,6 +108,12 @@ type testFixture struct { pairingStore *auth.InMemoryPairingStore sessionMgr *usersession.Manager + // userStore / deviceRepo 暴露給 DB-on FK 收尾的 test: + // - userStore:驗證 OIDC callback 後 user 被 provision(問題 #1)。 + // - deviceRepo:驗證 pairing exchange 後自建 device(問題 #2)。 + userStore *user.InMemoryStore + deviceRepo *device.InMemoryRepository + // router 暴露 *gin.Engine 給需要列出所有 route 的 test // (目前用於 all_endpoints_require_auth_test.go — Phase 0.7 security regression)。 router *gin.Engine @@ -211,21 +218,28 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa }) pairingStore := auth.NewInMemoryPairingStore() + sessionTokenStore := auth.NewInMemorySessionTokenStore() + deviceRepo := device.NewInMemoryRepository() + userStore := user.NewInMemoryStore() router := api.NewRouter(api.Deps{ Logger: logger, PairingStore: pairingStore, - SessionTokenStore: auth.NewInMemorySessionTokenStore(), + SessionTokenStore: sessionTokenStore, SessionStore: sessionStore, Forwarder: forwarder, - DeviceRepo: device.NewInMemoryRepository(), + DeviceRepo: deviceRepo, ModelRepo: model.NewInMemoryRepository(), Storage: storeStore, Converter: converter.NewStubClient(), // Phase 0.7 security fix C1:StaticUserID 已從 Deps 移除(見 internal/api/api.go:77-80 註解)。 // 整合測試走 fixture.AuthenticatedClient 完整 OIDC login flow 取 cookie,不再走 fallback 捷徑。 MaxUploadSizeMB: maxUploadMB, - RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL + RelayPublicURL: tunnelTS.URL, // 讓 exchange 測試能拿到真實 tunnel URL + + // DB-on FK 收尾:in-memory user store(問題 #1)+ in-memory pairing exchanger(問題 #2) + UserStore: userStore, + PairingExchanger: api.NewInMemoryPairingExchanger(deviceRepo, sessionTokenStore), // OIDC wiring(OB5) OIDCProvider: oidcProvider, @@ -244,6 +258,8 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa fakeOIDC: fakeOIDC, pairingStore: pairingStore, sessionMgr: sessionMgr, + userStore: userStore, + deviceRepo: deviceRepo, router: router, } } diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index e8ab085..d61ed7d 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -23,7 +23,6 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/api" "visiona-backend/internal/auth" @@ -38,6 +37,7 @@ import ( "visiona-backend/internal/oidc" "visiona-backend/internal/session" "visiona-backend/internal/storage" + "visiona-backend/internal/user" "visiona-backend/internal/usersession" ) @@ -265,6 +265,33 @@ func main() { log.Info("model repository initialized", "backend", "in-memory") } + // user:DB-on FK 收尾(問題 #1)— OIDC callback provision users 列用。 + // dbPool != nil 時切到 PostgresStore;否則 in-memory(local-dev fallback)。 + // D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。callback 一行不需改地切換。 + var userStore user.Store + if dbPool != nil { + userStore = user.NewPostgresStore(dbPool.Pool()) + log.Info("user store initialized", "backend", "postgres") + } else { + userStore = user.NewInMemoryStore() + log.Info("user store initialized", "backend", "in-memory") + } + + // pairing exchanger:DB-on FK 收尾(問題 #2)— exchange 時自建 device + 建 session token。 + // - Postgres:db.WithTx 把「建 device + 建 session token」包成單一交易,整筆原子。 + // - in-memory:依序執行(無交易),行為一致。 + // 注入 Deps.PairingExchanger;exchange handler 偵測非 nil 即走「自建 device」路徑。 + var pairingExchanger api.PairingExchanger + if dbPool != nil { + pairingExchanger = api.NewPostgresPairingExchanger( + dbPool.Pool(), pgDeviceRepo, pgSessionTokenStore, log) + log.Info("pairing exchanger initialized", "backend", "postgres-tx") + } else { + pairingExchanger = api.NewInMemoryPairingExchanger( + deviceRepo, memSessionTokenStore) + log.Info("pairing exchanger initialized", "backend", "in-memory") + } + // ===== Converter(stub,Phase 2 才實作) ===== converterClient := converter.NewStubClient() @@ -356,13 +383,9 @@ func main() { // ===== Seed demo data(可選) ===== if cfg.Server.SeedDemoData { - // dbPool 非 nil 時,seed 的 model 走 Postgres(塊 1):seedDemoData 內部會先 ensure - // demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。 - var seedPool *pgxpool.Pool - if dbPool != nil { - seedPool = dbPool.Pool() - } - if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, seedPool, log); err != nil { + // dbPool 非 nil 時,seed 的 model/device 走 Postgres(塊 1+):seedDemoData 內部會先透過 + // userStore.Upsert ensure demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。 + if err := seedDemoData(deviceRepo, modelRepo, pairingStore, userStore, cfg.Auth.StaticUserID, dbPool != nil, log); err != nil { log.Warn("seed demo data failed", "error", err) } } @@ -391,7 +414,8 @@ func main() { Forwarder: forwarder, DeviceRepo: deviceRepo, ModelRepo: modelRepo, - DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序) + DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序) + PairingExchanger: pairingExchanger, // DB-on FK 收尾 #2:exchange 自建 device + session token Storage: storageStore, Converter: converterClient, Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501) @@ -409,6 +433,7 @@ func main() { OIDCProvider: oidcProvider, SessionManager: userSessionMgr, OIDCPostLoginURL: cfg.OIDC.PostLoginURL, + UserStore: userStore, // DB-on FK 收尾 #1:OIDC callback provision users 列 }) addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)) diff --git a/visionA-backend/cmd/api-server/seed.go b/visionA-backend/cmd/api-server/seed.go index 376dbae..5750c09 100644 --- a/visionA-backend/cmd/api-server/seed.go +++ b/visionA-backend/cmd/api-server/seed.go @@ -6,11 +6,11 @@ import ( "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/auth" "visiona-backend/internal/device" "visiona-backend/internal/model" + "visiona-backend/internal/user" ) // demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。 @@ -34,15 +34,20 @@ const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3" // - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證) // - **不要**在生產環境啟用此 flag // -// dbPool 非 nil 表 model repo 已切到 Postgres(塊 1):此時 seed 的 model 必須用合法 UUID -// 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。 -// dbPool 為 nil(in-memory fallback)時行為與雛形完全相同。 +// dbBacked 為 true 表 repository 已切到 Postgres(塊 1+):此時 seed 的 model/device 必須用 +// 合法 UUID 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。 +// dbBacked 為 false(in-memory fallback)時行為與雛形完全相同。 +// +// demo user 透過注入的 userStore.Upsert 落地(DB-on 時 = PostgresStore,滿足 models/devices/ +// pairing_tokens 的 owner_user_id FK)——與 OIDC callback 走同一條 provision 路徑,避免 seed +// 自己手拼 INSERT users 與 store 行為不一致(問題 #1 的 seed 對齊)。 func seedDemoData( devRepo device.Repository, mdlRepo model.Repository, pairings auth.PairingStore, + userStore user.Store, userID string, - dbPool *pgxpool.Pool, + dbBacked bool, log *slog.Logger, ) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -61,17 +66,19 @@ func seedDemoData( pairingOwnerID := userID modelID := "demo-model-" + uuid.NewString()[:8] deviceID := "demo-device-" + uuid.NewString()[:8] - if dbPool != nil { + if dbBacked { modelOwnerID = demoSeedUserID deviceOwnerID = demoSeedUserID pairingOwnerID = demoSeedUserID modelID = uuid.NewString() deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串 // 先確保 owner user 存在(滿足 models / devices owner_user_id FK)。 - if _, err := dbPool.Exec(ctx, - `INSERT INTO users (id, email, name) VALUES ($1, $2, $3) - ON CONFLICT (id) DO NOTHING`, - demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil { + // 走 userStore.Upsert(DB-on = PostgresStore)與 OIDC callback 同一條 provision 路徑。 + if err := userStore.Upsert(ctx, &user.User{ + ID: demoSeedUserID, + Email: "demo@visiona.local", + Name: "Demo User (seeded)", + }); err != nil { log.Warn("seed: ensure demo user failed", "error", err) } } diff --git a/visionA-backend/cmd/api-server/seed_db_test.go b/visionA-backend/cmd/api-server/seed_db_test.go new file mode 100644 index 0000000..0d9db09 --- /dev/null +++ b/visionA-backend/cmd/api-server/seed_db_test.go @@ -0,0 +1,110 @@ +//go:build dbtest + +// seed_db_test.go — seedDemoData 的 DB-backed(dbBacked=true)整合測試。 +// +// Owner: testing agent(補測任務 — cmd/api-server 可測部分,DB 分支) +// +// build tag `dbtest`:只在 `go test -tags=dbtest` 時編譯/執行(需 Docker)。 +// 本機無 Docker,由 Orchestrator 在 130 跑: +// +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./cmd/api-server/ -run TestSeedDemoData_DBBacked -count=1 +// +// 對齊 seed.go docstring 的 DB-backed 契約: +// - 先 upsert demoSeedUserID 對應 users 列(滿足 models/devices/pairing_tokens 的 owner FK)。 +// - model/device id 改用合法 UUID(非雛形字串),owner 統一為 demoSeedUserID。 +// - 全部落 Postgres(用真 PG repository,非 in-memory)。 +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/auth" + "visiona-backend/internal/db/testsupport" + "visiona-backend/internal/device" + "visiona-backend/internal/model" + "visiona-backend/internal/user" +) + +// TestSeedDemoData_DBBacked_UpsertsUserAndSeedsAll:DB-backed seed 應 +// 1. upsert demoSeedUserID 進 users(滿足 FK) +// 2. 用 demoSeedUserID 當 owner 建 device + model + pairing token(全落 PG) +func TestSeedDemoData_DBBacked_UpsertsUserAndSeedsAll(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "pairing_tokens", "session_tokens", "models", "devices", "users") + + ctx := context.Background() + devRepo := device.NewPostgresRepository(tdb.Pool) + mdlRepo := model.NewPostgresRepository(tdb.Pool) + pairings := auth.NewPostgresPairingStore(tdb.Pool) + userStore := user.NewPostgresStore(tdb.Pool) + + // userID 傳入雛形 StaticUserID(非 UUID)—— DB-backed 分支應忽略它、改用 demoSeedUserID。 + require.NoError(t, seedDemoData( + devRepo, mdlRepo, pairings, userStore, + "demo-user", true /* dbBacked */, discardLog(), + )) + + // 1. demo user 已 upsert(owner FK 前提) + u, err := userStore.Get(ctx, demoSeedUserID) + require.NoError(t, err, "DB-backed seed 應 upsert demoSeedUserID") + assert.Equal(t, "demo@visiona.local", u.Email) + + // 2. device:owner = demoSeedUserID(非傳入的 demo-user) + devs, err := devRepo.List(ctx, demoSeedUserID) + require.NoError(t, err) + require.Len(t, devs, 1, "DB-backed seed 應建 1 個 device 掛在 demoSeedUserID") + assert.Equal(t, demoSeedUserID, devs[0].OwnerUserID) + assert.Equal(t, "kl520", devs[0].DeviceType) + // 註:device 確實掛在 demoSeedUserID(上方斷言已證),且 owner_user_id 為 UUID 欄位, + // 傳入的非-UUID "demo-user" 在型別上不可能成為任何 device 的 owner,故無需另查(查了反而會被 PG reject)。 + + // 3. model:owner = demoSeedUserID + mdls, err := mdlRepo.List(ctx, model.ListFilter{OwnerUserID: demoSeedUserID}) + require.NoError(t, err) + require.Len(t, mdls, 1, "DB-backed seed 應建 1 個 model 掛在 demoSeedUserID") + assert.Equal(t, demoSeedUserID, mdls[0].OwnerUserID) + assert.Equal(t, model.SourceUploaded, mdls[0].Source) + + // 4. pairing token:owner = demoSeedUserID + tokens, err := pairings.List(ctx, demoSeedUserID) + require.NoError(t, err) + require.Len(t, tokens, 1, "DB-backed seed 應建 1 個 pairing token 掛在 demoSeedUserID") + assert.Equal(t, demoSeedUserID, tokens[0].UserID) +} + +// TestSeedDemoData_DBBacked_GeneratesUUIDIDs:DB-backed 分支的 model/device id 必須是合法 UUID +// (schema 的 id 欄位為 UUID + FK;雛形的 "demo-model-xxx" 字串會違反型別)。 +func TestSeedDemoData_DBBacked_GeneratesUUIDIDs(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "pairing_tokens", "session_tokens", "models", "devices", "users") + + ctx := context.Background() + devRepo := device.NewPostgresRepository(tdb.Pool) + mdlRepo := model.NewPostgresRepository(tdb.Pool) + pairings := auth.NewPostgresPairingStore(tdb.Pool) + userStore := user.NewPostgresStore(tdb.Pool) + + require.NoError(t, seedDemoData( + devRepo, mdlRepo, pairings, userStore, + "demo-user", true, discardLog(), + )) + + devs, err := devRepo.List(ctx, demoSeedUserID) + require.NoError(t, err) + require.Len(t, devs, 1) + assert.Regexp(t, + `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, + devs[0].ID, "DB-backed device id 應為合法 UUID") + + mdls, err := mdlRepo.List(ctx, model.ListFilter{OwnerUserID: demoSeedUserID}) + require.NoError(t, err) + require.Len(t, mdls, 1) + assert.Regexp(t, + `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, + mdls[0].ID, "DB-backed model id 應為合法 UUID") +} diff --git a/visionA-backend/cmd/api-server/seed_test.go b/visionA-backend/cmd/api-server/seed_test.go new file mode 100644 index 0000000..60d98af --- /dev/null +++ b/visionA-backend/cmd/api-server/seed_test.go @@ -0,0 +1,165 @@ +// seed_test.go — seedDemoData 的 in-memory(dbBacked=false)unit test。 +// +// Owner: testing agent(補測任務 — cmd/api-server 可測部分) +// +// 對象:seedDemoData。in-memory 分支不需 DB,純 unit。DB-backed(dbBacked=true)分支 +// 需真 Postgres → 拆到 seed_db_test.go(//go:build dbtest,130 docker 跑)。 +// +// 行為對齊 seed.go docstring: +// - dbBacked=false 用傳入 userID(StaticUserID 概念)當 owner,model/device id 帶隨機後綴。 +// - 不 upsert demo user(in-memory 不需 FK)。 +// - seedDemoData 本身「每次呼叫都產新資料」(id 帶 uuid 後綴 / pairing 每次新建)—— +// 「重啟不重複 seed」是 main.go 只呼叫一次保證的,不是本函式的冪等性;故本檔不誤測冪等。 +package main + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/auth" + "visiona-backend/internal/device" + "visiona-backend/internal/model" + "visiona-backend/internal/user" +) + +// discardLog 丟棄 seed 的 log 噪音。 +func discardLog() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// seedInMemoryDeps 建立一組全 in-memory 的 seed 依賴。 +type seedDeps struct { + devRepo *device.InMemoryRepository + mdlRepo *model.InMemoryRepository + pairings *auth.InMemoryPairingStore + userStore *user.InMemoryStore +} + +func newSeedDeps() seedDeps { + return seedDeps{ + devRepo: device.NewInMemoryRepository(), + mdlRepo: model.NewInMemoryRepository(), + pairings: auth.NewInMemoryPairingStore(), + userStore: user.NewInMemoryStore(), + } +} + +// happy:in-memory seed 應建出 1 device + 1 model + 1 pairing token,全掛在傳入的 userID 下。 +func TestSeedDemoData_InMemory_SeedsAllEntities(t *testing.T) { + t.Parallel() + d := newSeedDeps() + const uid = "demo-user" // 雛形 StaticUserID 概念(in-memory 不需是合法 UUID) + + require.NoError(t, seedDemoData( + d.devRepo, d.mdlRepo, d.pairings, d.userStore, + uid, false /* dbBacked */, discardLog(), + )) + + ctx := context.Background() + + // device:owner = uid + devs, err := d.devRepo.List(ctx, uid) + require.NoError(t, err) + require.Len(t, devs, 1, "in-memory seed 應建 1 個 demo device") + assert.Equal(t, uid, devs[0].OwnerUserID) + assert.Equal(t, "kl520", devs[0].DeviceType) + assert.Equal(t, device.RemoteStatusOffline, devs[0].RemoteStatus) + + // model:owner = uid,StorageKey 用 owner 拼路徑 + mdls, err := d.mdlRepo.List(ctx, model.ListFilter{OwnerUserID: uid}) + require.NoError(t, err) + require.Len(t, mdls, 1, "in-memory seed 應建 1 個 demo model") + assert.Equal(t, uid, mdls[0].OwnerUserID) + assert.Equal(t, "kl520", mdls[0].TargetChip) + assert.Equal(t, model.SourceUploaded, mdls[0].Source) + assert.Equal(t, "models/"+uid+"/demo.nef", mdls[0].StorageKey) + require.NotNil(t, mdls[0].UploadedAt, "seed model 應設 UploadedAt(ready)") + + // pairing token:owner = uid + tokens, err := d.pairings.List(ctx, uid) + require.NoError(t, err) + require.Len(t, tokens, 1, "in-memory seed 應建 1 個 demo pairing token") + assert.Equal(t, uid, tokens[0].UserID) +} + +// in-memory 不 upsert demo user(不需 FK);userStore 應維持空(demoSeedUserID 不存在)。 +func TestSeedDemoData_InMemory_DoesNotUpsertDemoUser(t *testing.T) { + t.Parallel() + d := newSeedDeps() + + require.NoError(t, seedDemoData( + d.devRepo, d.mdlRepo, d.pairings, d.userStore, + "demo-user", false, discardLog(), + )) + + // dbBacked=false 不應走 userStore.Upsert(demoSeedUserID) + _, err := d.userStore.Get(context.Background(), demoSeedUserID) + assert.Error(t, err, "in-memory seed 不應 upsert demoSeedUserID(無 FK 需求)") +} + +// boundary / concurrency-ish:seedDemoData 每次呼叫產生獨立資料(id 帶隨機後綴)—— +// 連呼叫兩次同一 userID 應累積 2 device / 2 model / 2 token(對齊 docstring:本函式非冪等, +// 「不重複 seed」由 main 只呼叫一次保證)。此測試把該行為釘住,避免有人誤改成「靜默去重」。 +func TestSeedDemoData_InMemory_CalledTwice_Accumulates(t *testing.T) { + t.Parallel() + d := newSeedDeps() + const uid = "demo-user" + log := discardLog() + + require.NoError(t, seedDemoData(d.devRepo, d.mdlRepo, d.pairings, d.userStore, uid, false, log)) + require.NoError(t, seedDemoData(d.devRepo, d.mdlRepo, d.pairings, d.userStore, uid, false, log)) + + ctx := context.Background() + devs, err := d.devRepo.List(ctx, uid) + require.NoError(t, err) + assert.Len(t, devs, 2, "兩次呼叫應產生 2 個 device(id 帶隨機後綴、不去重)") + + mdls, err := d.mdlRepo.List(ctx, model.ListFilter{OwnerUserID: uid}) + require.NoError(t, err) + assert.Len(t, mdls, 2, "兩次呼叫應產生 2 個 model") + + tokens, err := d.pairings.List(ctx, uid) + require.NoError(t, err) + assert.Len(t, tokens, 2, "兩次呼叫應產生 2 個 pairing token") +} + +// error path:底層 repo Save 失敗時 seedDemoData 只 log warning、不回 error、不中斷(fail-soft)。 +// 用一個 Save 永遠失敗的 device repo 驗「seed 失敗不阻擋啟動」契約。 +func TestSeedDemoData_InMemory_DeviceSaveFails_StillReturnsNil(t *testing.T) { + t.Parallel() + d := newSeedDeps() + + err := seedDemoData( + failingDeviceRepo{}, // device.Save 永遠失敗 + d.mdlRepo, d.pairings, d.userStore, + "demo-user", false, discardLog(), + ) + require.NoError(t, err, "device save 失敗時 seedDemoData 仍應回 nil(fail-soft,不阻擋啟動)") + + // model / pairing 仍應照常建立(device 失敗不影響後續步驟) + mdls, err := d.mdlRepo.List(context.Background(), model.ListFilter{OwnerUserID: "demo-user"}) + require.NoError(t, err) + assert.Len(t, mdls, 1, "device save 失敗不應中斷 model 建立") +} + +// failingDeviceRepo 是 device.Save 永遠回錯的 device.Repository(其餘方法委派給空行為)。 +type failingDeviceRepo struct{} + +func (failingDeviceRepo) Get(ctx context.Context, id string) (*device.Device, error) { + return nil, device.ErrNotFound +} +func (failingDeviceRepo) GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error) { + return nil, device.ErrNotFound +} +func (failingDeviceRepo) List(ctx context.Context, ownerUserID string) ([]*device.Device, error) { + return nil, nil +} +func (failingDeviceRepo) Save(ctx context.Context, dev *device.Device) error { + return assert.AnError +} +func (failingDeviceRepo) Delete(ctx context.Context, id string) error { return nil } diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index 6f7ce51..eaa33cb 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -31,6 +31,7 @@ import ( "visiona-backend/internal/oidc" "visiona-backend/internal/session" "visiona-backend/internal/storage" + "visiona-backend/internal/user" "visiona-backend/internal/usersession" ) @@ -62,6 +63,16 @@ type Deps struct { // 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。 OIDCPostLoginURL string + // UserStore 在 OIDC callback 驗 id_token 成功後 provision(upsert)一筆 users 列 + // (DB-on FK 收尾,問題 #1)。D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。 + // + // 為何必須:DB-on(有 FK)下,真人登入後任何帶 owner_user_id FK 的寫入(上傳 model、配對、 + // 發 pairing token)都需要 users 表已有對應列;不 upsert → FK violation。 + // + // 為 nil 時 callback 略過 upsert(最小骨架 / 純 OIDC unit test 不強制注入); + // main.go 依 dbPool 注入 PostgresStore 或 InMemoryStore,兩模式都會 upsert 對齊行為。 + UserStore user.Store + SessionStore session.Store Forwarder *session.Forwarder @@ -119,6 +130,14 @@ type Deps struct { // 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。 RelayPublicURL string + // PairingExchanger 在 pairing exchange 時自建一筆 device 並建綁該 device 的 session token + // (DB-on FK 收尾,問題 #2:session_tokens.device_id NOT NULL FK,但雛形流程從不建 device)。 + // - Postgres:用 db.WithTx 把「建 device + 建 session token」包成單一交易,整筆原子。 + // - in-memory:依序執行(無交易),行為一致。 + // 為 nil 時 exchange handler fallback 到舊行為(直接用 info.DeviceID 建 session token, + // 不自建 device)——僅 DB-off 雛形相容用。main.go 依 dbPool 擇一注入。 + PairingExchanger PairingExchanger + // HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴(DB 接入塊 5.4)。 // 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、 // /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。 diff --git a/visionA-backend/internal/api/errors_db.go b/visionA-backend/internal/api/errors_db.go index a42adbd..c9cf8cf 100644 --- a/visionA-backend/internal/api/errors_db.go +++ b/visionA-backend/internal/api/errors_db.go @@ -99,3 +99,28 @@ func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) { WriteError(c, cls.status, cls.code, cls.message, nil) } + +// WriteStorageError 把一個 storage(物件儲存)操作錯誤映射成對外 API 錯誤並寫回 response, +// 同時把 raw error 進 server log(含 request_id)。 +// +// 動機(塊 5 Minor-1):presigned URL / Stat 等 storage 操作失敗時,handler 過去把 +// err.Error() 直接串進 response message,可能洩漏 storage 後端細節(bucket / endpoint / +// 內部路徑 / 簽章參數)給前端。本函式比照 WriteDBError,對外只給穩定 code + 通用 message, +// raw error 只進 server log。 +// +// 對外一律 500 INTERNAL_ERROR:storage 後端不可用 / 設定錯誤對前端而言都是「伺服器端問題」, +// 不細分(與 WriteDBError 的 503 區隔開——DB 是 fail-fast 拉機制的依賴、storage 不是)。 +// 呼叫端應先處理 storage sentinel(如 storage.ErrNotFound)回對應的 4xx,再把剩下的 +// 「真 storage 錯誤」交給本函式。 +// +// op 是操作描述(如 "presigned put url" / "stat object"),只進 log、不對外。 +func WriteStorageError(c *gin.Context, log *slog.Logger, op string, err error) { + logOrDefault(log).Error("storage operation failed", + "op", op, + "error", err, + "http_status", http.StatusInternalServerError, + "code", ErrCodeInternalError, + "request_id", RequestIDFrom(c)) + + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "internal error", nil) +} diff --git a/visionA-backend/internal/api/errors_test.go b/visionA-backend/internal/api/errors_test.go new file mode 100644 index 0000000..9c32be5 --- /dev/null +++ b/visionA-backend/internal/api/errors_test.go @@ -0,0 +1,172 @@ +// errors_test.go — errors.go 3 個純 helper(WriteError / WriteSuccess / WriteNotImplemented)的 unit test。 +// +// Owner: testing agent(補測任務 — internal/api 弱處純函式) +// +// 這 3 個函式直接寫 gin.Context 的 JSON response(envelope 形狀對齊 api-spec.md §11)。 +// 用 httptest.NewRecorder() + gin.CreateTestContext 驅動,驗 status code + envelope 結構 + +// request_id 帶入 + details / nil 邊界。 +// +// 注意:errors_db.go 的 DB 錯誤映射另由 errors_db_test.go 覆蓋,本檔不重複。 +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestCtx 建立一個帶 ResponseRecorder 的 gin.Context;reqID 非空時塞入 request_id。 +func newTestCtx(reqID string) (*gin.Context, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + if reqID != "" { + c.Set(ctxKeyRequestID, reqID) + } + return c, w +} + +// decodeErrorBody 把 recorder body 解成 ErrorBody。 +func decodeErrorBody(t *testing.T, w *httptest.ResponseRecorder) ErrorBody { + t.Helper() + var body ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body +} + +// ───────────────────────── WriteError ───────────────────────── + +// happy:寫一個帶 request_id 的 404 錯誤,envelope 形狀正確。 +func TestWriteError_Happy_WithRequestID(t *testing.T) { + c, w := newTestCtx("req-123") + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + + body := decodeErrorBody(t, w) + assert.False(t, body.Success, "錯誤 envelope success 必為 false") + require.NotNil(t, body.Error) + assert.Equal(t, ErrCodeNotFound, body.Error.Code) + assert.Equal(t, "device not found", body.Error.Message) + assert.Equal(t, "req-123", body.Error.RequestID, "WriteError 應自動帶上 request_id") + assert.Nil(t, body.Error.Details, "未傳 details 時應為 nil(omitempty)") +} + +// boundary:帶 details(validation 細節)時,details 應出現在 envelope。 +func TestWriteError_WithDetails(t *testing.T) { + c, w := newTestCtx("req-val") + details := []FieldError{ + {Field: "name", Message: "required"}, + {Field: "target_chip", Message: "unsupported"}, + } + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "validation failed", details) + + assert.Equal(t, http.StatusBadRequest, w.Code) + body := decodeErrorBody(t, w) + require.NotNil(t, body.Error) + require.Len(t, body.Error.Details, 2) + assert.Equal(t, "name", body.Error.Details[0].Field) + assert.Equal(t, "required", body.Error.Details[0].Message) + assert.Equal(t, "target_chip", body.Error.Details[1].Field) +} + +// empty / edge:沒有 request_id 時,request_id 欄位被 omitempty 省略(不應出現空字串 key 影響 client)。 +func TestWriteError_NoRequestID_OmitsField(t *testing.T) { + c, w := newTestCtx("") // 不塞 request_id + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "boom", nil) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + + // request_id 為 omitempty,空值時不應出現在 JSON + raw := w.Body.String() + assert.NotContains(t, raw, "request_id", "request_id 為空時應被 omitempty 省略") + + body := decodeErrorBody(t, w) + require.NotNil(t, body.Error) + assert.Equal(t, ErrCodeInternalError, body.Error.Code) + assert.Empty(t, body.Error.RequestID) +} + +// edge:空 details slice(len 0)→ omitempty 省略(不應出現 "details": [])。 +func TestWriteError_EmptyDetailsSlice_Omitted(t *testing.T) { + c, w := newTestCtx("req-x") + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "msg", []FieldError{}) + + raw := w.Body.String() + assert.NotContains(t, raw, "details", "空 details slice 應被 omitempty 省略") +} + +// ───────────────────────── WriteSuccess ───────────────────────── + +// happy:寫一個 200 成功回應,data 帶入。 +func TestWriteSuccess_Happy(t *testing.T) { + c, w := newTestCtx("") + payload := map[string]any{"id": "m-1", "name": "yolo"} + WriteSuccess(c, http.StatusOK, payload) + + assert.Equal(t, http.StatusOK, w.Code) + + var body SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.True(t, body.Success, "成功 envelope success 必為 true") + + data, ok := body.Data.(map[string]any) + require.True(t, ok) + assert.Equal(t, "m-1", data["id"]) + assert.Equal(t, "yolo", data["name"]) +} + +// edge:data 為 nil(如 201 Created 無 body)→ data 被 omitempty 省略,但 success 仍在。 +func TestWriteSuccess_NilData_OmitsData(t *testing.T) { + c, w := newTestCtx("") + WriteSuccess(c, http.StatusCreated, nil) + + assert.Equal(t, http.StatusCreated, w.Code) + raw := w.Body.String() + assert.Contains(t, raw, `"success":true`) + assert.NotContains(t, raw, "data", "nil data 應被 omitempty 省略") +} + +// boundary:non-201 status 也能用(WriteSuccess 不限定 status)。 +func TestWriteSuccess_CustomStatus(t *testing.T) { + c, w := newTestCtx("") + WriteSuccess(c, http.StatusAccepted, map[string]string{"state": "queued"}) + + assert.Equal(t, http.StatusAccepted, w.Code) + var body SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.True(t, body.Success) +} + +// ───────────────────────── WriteNotImplemented ───────────────────────── + +// happy:回 501 NOT_IMPLEMENTED,hint 帶進 message。 +func TestWriteNotImplemented_Happy(t *testing.T) { + c, w := newTestCtx("req-ni") + WriteNotImplemented(c, "clusters endpoint pending B5") + + assert.Equal(t, http.StatusNotImplemented, w.Code) + body := decodeErrorBody(t, w) + require.NotNil(t, body.Error) + assert.False(t, body.Success) + assert.Equal(t, ErrCodeNotImplemented, body.Error.Code) + assert.Equal(t, "clusters endpoint pending B5", body.Error.Message) + assert.Equal(t, "req-ni", body.Error.RequestID) +} + +// edge:空 hint 也成立(message 為空字串、code 仍為 NOT_IMPLEMENTED)。 +func TestWriteNotImplemented_EmptyHint(t *testing.T) { + c, w := newTestCtx("") + WriteNotImplemented(c, "") + + assert.Equal(t, http.StatusNotImplemented, w.Code) + body := decodeErrorBody(t, w) + require.NotNil(t, body.Error) + assert.Equal(t, ErrCodeNotImplemented, body.Error.Code) + assert.Empty(t, body.Error.Message) +} diff --git a/visionA-backend/internal/api/models.go b/visionA-backend/internal/api/models.go index 9e90cb6..afece59 100644 --- a/visionA-backend/internal/api/models.go +++ b/visionA-backend/internal/api/models.go @@ -53,16 +53,23 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) { // ModelResponse 是 API 回傳的 Model DTO;對應 api-spec.md §4 的格式。 type ModelResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - TargetChip string `json:"target_chip,omitempty"` - FileSize int64 `json:"file_size"` - Source string `json:"source"` - Status string `json:"status"` // "pending" / "ready" - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - UploadedAt *time.Time `json:"uploaded_at,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TargetChip string `json:"target_chip,omitempty"` + FileSize int64 `json:"file_size"` + Source string `json:"source"` + Status string `json:"status"` // "pending" / "ready" + // 模型 metadata(B4 鏈路最後一個序列化點)。 + // snake_case 對齊本 DTO 既有慣例(target_chip / file_size / created_at); + // 前端 model-store normalize 雙吃 snake/camel,input_shape(snake) 在其讀取範圍內。 + // omitempty:上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。 + InputShape []int `json:"input_shape,omitempty"` + Classes []string `json:"classes,omitempty"` + Framework string `json:"framework,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UploadedAt *time.Time `json:"uploaded_at,omitempty"` } // toModelResponse 把 domain model 轉為 API DTO;「status」由 UploadedAt 是否 set 決定。 @@ -79,6 +86,9 @@ func toModelResponse(m *model.Model) ModelResponse { FileSize: m.FileSize, Source: m.Source, Status: status, + InputShape: m.InputShape, + Classes: m.Classes, + Framework: m.Framework, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, UploadedAt: m.UploadedAt, @@ -244,8 +254,8 @@ func modelsInitUploadHandler(deps Deps) gin.HandlerFunc { // 產 presigned PUT URL uploadURL, err := deps.Storage.PresignedPutURL(ctx, storageKey, modelUploadURLTTL) if err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "presigned url failed: "+err.Error(), nil) + // 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。 + WriteStorageError(c, deps.Logger, "presigned put url", err) return } @@ -343,8 +353,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc { "file not uploaded yet; PUT to upload_url first", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "stat storage failed: "+statErr.Error(), nil) + // 塊 5 Minor-1:storage 錯誤統一映射,不把 raw err(bucket/endpoint 等)洩漏給前端。 + WriteStorageError(c, deps.Logger, "stat object", statErr) return } // Size 驗證(雛形只比對 size;Phase 1 加 checksum) diff --git a/visionA-backend/internal/api/models_test.go b/visionA-backend/internal/api/models_test.go index e1eb26e..5824c15 100644 --- a/visionA-backend/internal/api/models_test.go +++ b/visionA-backend/internal/api/models_test.go @@ -183,6 +183,122 @@ func TestModelsDelete_NotOwner(t *testing.T) { assert.Equal(t, http.StatusForbidden, w.Code) } +// TestModelsGet_HTTPResponseCarriesMetadata 驗證 B4 metadata 鏈路的最後序列化點: +// GET /api/models/:id 的 HTTP JSON response 真的含 input_shape / classes / framework。 +// +// 這是先前測試的盲區——舊測試只測到 model.Model 落地(dbtest)為止、沒測最外層 HTTP +// JSON。此測試直接 call handler → 解析 response JSON → 斷言三欄都在裡面且值正確。 +func TestModelsGet_HTTPResponseCarriesMetadata(t *testing.T) { + r, repo, _ := newModelsFixture(t) + + now := time.Now().UTC() + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: "mdl-meta", + OwnerUserID: "demo-user", + Name: "metamodel", + Source: model.SourceConverted, + InputShape: []int{1, 3, 224, 224}, + Classes: []string{"cat", "dog"}, + Framework: "onnx", + CreatedAt: now, + UpdatedAt: now, + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-meta", nil)) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) + data, ok := sb.Data.(map[string]any) + require.True(t, ok, "data should be an object") + + // input_shape 必須在最外層 HTTP JSON 裡(snake_case,前端 normalize 讀得到)。 + rawShape, present := data["input_shape"] + require.True(t, present, "input_shape must be present in HTTP JSON response; body=%s", w.Body.String()) + shape, ok := rawShape.([]any) + require.True(t, ok) + require.Len(t, shape, 4) + assert.EqualValues(t, 1, shape[0]) + assert.EqualValues(t, 3, shape[1]) + assert.EqualValues(t, 224, shape[2]) + assert.EqualValues(t, 224, shape[3]) + + // classes / framework 同樣要帶到。 + classes, ok := data["classes"].([]any) + require.True(t, ok, "classes must be present; body=%s", w.Body.String()) + require.Len(t, classes, 2) + assert.Equal(t, "cat", classes[0]) + assert.Equal(t, "dog", classes[1]) + + assert.Equal(t, "onnx", data["framework"]) +} + +// TestModelsGet_OmitsMetadataWhenAbsent 驗證 omitempty:無 metadata 的 model(如上傳類 / +// 舊 model)回應不含 input_shape / classes / framework,不破壞既有回應結構。 +func TestModelsGet_OmitsMetadataWhenAbsent(t *testing.T) { + r, repo, _ := newModelsFixture(t) + + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: "mdl-nometa", + OwnerUserID: "demo-user", + Name: "plain", + Source: model.SourceUploaded, + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-nometa", nil)) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) + data, ok := sb.Data.(map[string]any) + require.True(t, ok) + + _, hasShape := data["input_shape"] + _, hasClasses := data["classes"] + _, hasFramework := data["framework"] + assert.False(t, hasShape, "input_shape should be omitted when empty") + assert.False(t, hasClasses, "classes should be omitted when empty") + assert.False(t, hasFramework, "framework should be omitted when empty") +} + +// TestModelsList_HTTPResponseCarriesMetadata 驗證 list endpoint(GET /api/models)的 +// HTTP JSON 也帶 input_shape(列表頁雖非 B4 主顯示處,仍確認鏈路一致)。 +func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) { + r, repo, _ := newModelsFixture(t) + + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: "mdl-list-meta", + OwnerUserID: "demo-user", + Name: "listed", + Source: model.SourceConverted, + InputShape: []int{1, 28, 28}, + Framework: "tflite", + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) + arr, ok := sb.Data.([]any) + require.True(t, ok) + require.Len(t, arr, 1) + first := arr[0].(map[string]any) + + rawShape, present := first["input_shape"] + require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String()) + shape, ok := rawShape.([]any) + require.True(t, ok) + require.Len(t, shape, 3) + assert.EqualValues(t, 1, shape[0]) + assert.EqualValues(t, 28, shape[1]) + assert.EqualValues(t, 28, shape[2]) + assert.Equal(t, "tflite", first["framework"]) +} + // TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。 func TestModelsList_FiltersByOwner(t *testing.T) { r, repo, _ := newModelsFixture(t) diff --git a/visionA-backend/internal/api/oidc_auth.go b/visionA-backend/internal/api/oidc_auth.go index d44e98c..2bad458 100644 --- a/visionA-backend/internal/api/oidc_auth.go +++ b/visionA-backend/internal/api/oidc_auth.go @@ -32,6 +32,7 @@ import ( "github.com/gin-gonic/gin" "visiona-backend/internal/oidc" + "visiona-backend/internal/user" ) // oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。 @@ -228,6 +229,32 @@ func oidcCallbackHandler(deps Deps) gin.HandlerFunc { return } + // Provision users 列(DB-on FK 收尾,問題 #1)。 + // + // D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。upsert 後 users 表才有 + // 這筆使用者,後續帶 owner_user_id FK 的寫入(model 上傳 / 配對 / pairing token)才不會 + // FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK,僅維持對稱。 + // + // 在「驗 id_token 成功後、寫 session 之前」provision:fail-closed —— provision 失敗就不發 + // session(否則使用者拿到能登入的 cookie 但 DB 沒對應 user,下一個寫入照樣爆,且更難診斷)。 + // UserStore 為 nil(最小骨架 / 純 OIDC unit test)→ 略過 upsert。 + if deps.UserStore != nil { + if upErr := deps.UserStore.Upsert(ctx, &user.User{ + ID: claims.Subject, // = users.id(D1-B) + Email: claims.Email, + Name: claims.Name, + }); upErr != nil { + // 不洩漏 raw error 給 user;log 留診斷(不含 token / secret)。 + log.Error("oidc.callback: provision user failed", + "request_id", RequestIDFrom(c), + "user_id", claims.Subject, + "error", upErr) + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "failed to provision user", nil) + return + } + } + // Session fixation 防護(OWASP ASVS V3.2.1)— Fix-A1 / Major-1。 // // 在「驗 id_token 成功後、寫使用者 info 進 session 之前」rotate session ID。 @@ -402,4 +429,3 @@ func sanitizeReturnTo(raw string) string { } return raw } - diff --git a/visionA-backend/internal/api/pairing.go b/visionA-backend/internal/api/pairing.go index 2d3e5d7..ef7c252 100644 --- a/visionA-backend/internal/api/pairing.go +++ b/visionA-backend/internal/api/pairing.go @@ -421,21 +421,55 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc { return } - // Generate Session Token - plaintext, sessionInfo, err := deps.SessionTokenStore.Create( - ctx, - info.UserID, - info.DeviceID, // Pairing Token 雛形還沒綁 device_id,為空沒關係 - info.TokenHash, - auth.SessionTokenTTL, + // 自建 device + 建綁該 device 的 session token(DB-on FK 收尾,問題 #2)。 + // + // session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。 + // 改由 PairingExchanger 在 exchange 時自建一筆 device(owner = info.UserID,該 user 已透過 + // OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用 + // 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback)。 + // + // info.TokenHash 作為 parent_token_hash(稽核鏈:session token ← pairing token)。 + // + // PairingExchanger 為 nil(DB-off 雛形最小骨架,未注入)→ fallback 到舊行為:直接用 + // info.DeviceID(可能為空)建 session token,不自建 device。in-memory store 不檢查 FK, + // 空 deviceID 可接受;此分支只在 main.go 沒注入 exchanger 時走到。 + var ( + plaintext string + sessionInfo *auth.SessionToken + deviceID string ) - if err != nil { - logOrDefault(deps.Logger).Error("pairing exchange: create session token failed", - "error", err, - "request_id", RequestIDFrom(c)) - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "failed to create session token", nil) - return + if deps.PairingExchanger != nil { + res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL) + if exErr != nil { + logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed", + "error", exErr, + "user_id", info.UserID, + "request_id", RequestIDFrom(c)) + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "failed to create session token", nil) + return + } + plaintext = res.SessionPlaintext + sessionInfo = res.SessionInfo + deviceID = res.DeviceID + } else { + var err error + plaintext, sessionInfo, err = deps.SessionTokenStore.Create( + ctx, + info.UserID, + info.DeviceID, // fallback:雛形未綁 device_id,為空(僅 in-memory 可接受) + info.TokenHash, + auth.SessionTokenTTL, + ) + if err != nil { + logOrDefault(deps.Logger).Error("pairing exchange: create session token failed", + "error", err, + "request_id", RequestIDFrom(c)) + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "failed to create session token", nil) + return + } + deviceID = info.DeviceID } // Mark pairing token as used。 @@ -445,9 +479,9 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc { // 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500, // 而不是 silent log warn 繼續往前。 // - // 注意:deviceID 沿用 info.DeviceID(可能為空)。雛形 MarkUsed 對空字串 - // 是安全的(它只是覆寫欄位)。 - if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.DeviceID); err != nil { + // 注意:deviceID 現在綁的是自建 device(DB-on)或 info.DeviceID(fallback)。 + // MarkUsed 把 pairing token 也綁上同一個 device_id(稽核:哪台 device 用掉了這個 token)。 + if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, deviceID); err != nil { // 嘗試 revoke 剛產生的 session token;revoke 自身失敗不再 retry,只 log。 revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext) logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted", @@ -455,7 +489,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc { "revoke_err", revokeErr, "token_prefix", tokenPrefix(req.PairingToken), "session_token_prefix", tokenPrefix(plaintext), - "device_id", info.DeviceID, + "device_id", deviceID, "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "pairing token mark-used failed; aborted", nil) @@ -482,7 +516,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc { // 故意只 log token prefix,避免完整 session token 進日誌 logOrDefault(deps.Logger).Info("pairing exchange: success", "user_id", info.UserID, - "device_id", info.DeviceID, + "device_id", deviceID, "session_token_prefix", tokenPrefix(plaintext), "pairing_token_prefix", tokenPrefix(req.PairingToken), "request_id", RequestIDFrom(c)) diff --git a/visionA-backend/internal/api/pairing_exchange.go b/visionA-backend/internal/api/pairing_exchange.go new file mode 100644 index 0000000..b3d4b89 --- /dev/null +++ b/visionA-backend/internal/api/pairing_exchange.go @@ -0,0 +1,210 @@ +// pairing_exchange.go — pairing exchange 自建 device 的協調者(DB-on FK 收尾,問題 #2)。 +// +// 背景: +// +// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何 +// production 路徑會建 device(grep 確認 device.Save 只在 seed / test 被呼叫)。exchange 時 +// info.DeviceID 必為空 → DB-on 下 session token INSERT 因 device_id 空字串 cast UUID 失敗。 +// in-memory 模式因為不檢查 FK 而藏住此問題。 +// +// 修法(使用者拍板:exchange 時雲端自建 device,不動 local-tool): +// +// exchange 驗完 pairing token 後、建 session token 之前,雲端自建一筆 device 代表「這台配對 +// 進來的 local agent」(owner = pairing token 綁的 user,這個 user 已透過 OIDC callback +// provision 進 users 表 —— 見問題 #1)。然後用這個 device_id 建 session token。 +// +// 為什麼抽成 coordinator(比照 unpair.go 的 DeviceUnpairer): +// - 讓 handler(pairing.go 的 exchange)維持薄。 +// - Postgres 後端用 db.WithTx 把「建 device + 建 session token」包成單一交易——任一步失敗 +// 整筆 rollback,杜絕「device 建了但 session token 沒建成」的中間態(database.md §6 一致性精神)。 +// - in-memory 後端依序執行(無交易),行為一致。 +// - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler +// fallback 到「不自建 device、直接用 info.DeviceID(可能為空)建 session token」的舊行為 +// (與 DB-off 雛形相容;in-memory store 不檢查 FK,空 deviceID 可接受)。 +// +// 冪等:pairing token 是一次性(MarkUsed 後 Validate 回 ErrTokenUsed),故同一 token 不會被 +// exchange 兩次成功。每次成功 exchange 自建一筆新 device(新 UUID)是正確語意——不同次配對 +// 視為不同 agent 連線。重試(exchange 後 MarkUsed 失敗被 abort)時 session token 已 revoke、 +// device 已建但無 token 指向它(孤兒 device,無安全風險,僅一筆閒置紀錄;雛形可接受)。 +package api + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/auth" + "visiona-backend/internal/db" + "visiona-backend/internal/device" +) + +// defaultPairedDeviceName / defaultPairedDeviceType 是 exchange 自建 device 的預設值。 +// +// 雛形:agent 端 exchange request 只傳 pairing_token、不帶裝置資訊(不動 local-tool), +// 故 Name / DeviceType 在雲端用預設值。Phase 1 若 agent 帶上 serial / device_type 可改填真值。 +const ( + defaultPairedDeviceName = "local-tool (paired)" + defaultPairedDeviceType = "local-agent" +) + +// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。 +type ExchangeProvisionResult struct { + DeviceID string // 本次自建的 device id + SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到) + SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt) +} + +// PairingExchanger 把「自建 device + 建 session token」包成一個原子(Postgres tx)或 +// 一致(in-memory 依序)操作。 +// +// Provision 語意:成功回 ExchangeProvisionResult;任一步失敗回 error(handler 經 errors.go +// 映射成 5xx,不洩漏 raw error)。 +type PairingExchanger interface { + // Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。 + // + // parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。 + Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration) (ExchangeProvisionResult, error) +} + +// ── Postgres 後端 ───────────────────────────────────────────────────────────── + +// pgDeviceSaver 是 device 在 tx 內 upsert 的能力(由 device.PostgresRepository 滿足)。 +type pgDeviceSaver interface { + SaveTx(ctx context.Context, q db.Querier, d *device.Device) error +} + +// pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。 +type pgSessionTokenCreator interface { + CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error) +} + +// pgPairingExchanger 用單一 pgx 交易完成「自建 device + 建 session token」。 +type pgPairingExchanger struct { + pool *pgxpool.Pool + devices pgDeviceSaver + sessionToken pgSessionTokenCreator + log *slog.Logger +} + +// NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。 +func NewPostgresPairingExchanger( + pool *pgxpool.Pool, + devices pgDeviceSaver, + sessionToken pgSessionTokenCreator, + log *slog.Logger, +) PairingExchanger { + return &pgPairingExchanger{ + pool: pool, + devices: devices, + sessionToken: sessionToken, + log: logOrDefault(log), + } +} + +// Provision 在單一交易內:自建 device → 建綁該 device 的 session token。 +// +// 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。 +func (e *pgPairingExchanger) Provision( + ctx context.Context, userID, parentTokenHash string, ttl time.Duration, +) (ExchangeProvisionResult, error) { + var res ExchangeProvisionResult + deviceID := uuid.NewString() + now := time.Now().UTC() + + err := db.WithTx(ctx, e.pool, func(q db.Querier) error { + dev := &device.Device{ + ID: deviceID, + OwnerUserID: userID, + Name: defaultPairedDeviceName, + DeviceType: defaultPairedDeviceType, + // serial_number 留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL, + // 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞 + // partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。 + RemoteStatus: device.RemoteStatusOffline, + Status: device.USBStatusUnknown, + PairedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } + if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil { + return fmt.Errorf("exchange: save device: %w", saveErr) + } + + plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, deviceID, parentTokenHash, ttl) + if createErr != nil { + return fmt.Errorf("exchange: create session token: %w", createErr) + } + res.DeviceID = deviceID + res.SessionPlaintext = plaintext + res.SessionInfo = info + return nil + }) + if err != nil { + return ExchangeProvisionResult{}, err + } + return res, nil +} + +// ── in-memory 後端 ──────────────────────────────────────────────────────────── + +// memSessionTokenCreator 是 in-memory store「建 session token」的能力 +// (由 auth.InMemorySessionTokenStore 透過 SessionTokenStore interface 滿足)。 +type memSessionTokenCreator interface { + Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error) +} + +// memPairingExchanger 依序(非交易)完成自建 device + 建 session token。 +// +// in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致。 +type memPairingExchanger struct { + devices device.Repository + sessionToken memSessionTokenCreator +} + +// NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。 +func NewInMemoryPairingExchanger( + devices device.Repository, + sessionToken memSessionTokenCreator, +) PairingExchanger { + return &memPairingExchanger{ + devices: devices, + sessionToken: sessionToken, + } +} + +// Provision 自建 device 後建綁該 device 的 session token(依序,非交易)。 +func (e *memPairingExchanger) Provision( + ctx context.Context, userID, parentTokenHash string, ttl time.Duration, +) (ExchangeProvisionResult, error) { + deviceID := uuid.NewString() + now := time.Now().UTC() + + dev := &device.Device{ + ID: deviceID, + OwnerUserID: userID, + Name: defaultPairedDeviceName, + DeviceType: defaultPairedDeviceType, + RemoteStatus: device.RemoteStatusOffline, + Status: device.USBStatusUnknown, + PairedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } + if err := e.devices.Save(ctx, dev); err != nil { + return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err) + } + + plaintext, info, err := e.sessionToken.Create(ctx, userID, deviceID, parentTokenHash, ttl) + if err != nil { + return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err) + } + return ExchangeProvisionResult{ + DeviceID: deviceID, + SessionPlaintext: plaintext, + SessionInfo: info, + }, nil +} diff --git a/visionA-backend/internal/api/pairing_exchange_db_test.go b/visionA-backend/internal/api/pairing_exchange_db_test.go new file mode 100644 index 0000000..e33285d --- /dev/null +++ b/visionA-backend/internal/api/pairing_exchange_db_test.go @@ -0,0 +1,118 @@ +//go:build dbtest + +// Postgres pairing exchange 自建 device 的真 DB 整合測試(DB-on FK 收尾,問題 #2)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。 +// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/api/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/api/... +// +// 涵蓋: +// - Provision 成功:自建一筆 device(owner 對齊)+ 建綁該 device 的 session token,session +// token 的 device_id 不再為空、且確實指向新建的 device(FK 滿足)。 +// - parent_token_hash 寫入(稽核鏈)。 +// - 原子性:device owner 不存在(FK violation)→ 整筆 rollback,device 不會殘留。 +package api + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/auth" + "visiona-backend/internal/db/testsupport" + "visiona-backend/internal/device" +) + +// pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user(無 device、無 token)。 +func pgExchangeFixture(t *testing.T) ( + tdb *testsupport.TestDB, + exchanger PairingExchanger, + devRepo *device.PostgresRepository, + sessions *auth.PostgresSessionTokenStore, + owner string, +) { + t.Helper() + tdb = testsupport.SetupTestDB(t) + tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users") + owner = tdb.EnsureDemoUser(t) + + devRepo = device.NewPostgresRepository(tdb.Pool) + sessions = auth.NewPostgresSessionTokenStore(tdb.Pool) + exchanger = NewPostgresPairingExchanger(tdb.Pool, devRepo, sessions, nil) + return +} + +// TestPGExchange_ProvisionCreatesDeviceAndSession 驗證 exchange 自建 device + session token, +// 且 session token 的 device_id 綁定到新建的 device(之前 DB-on 會因 device_id 空字串失敗)。 +func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) { + ctx := context.Background() + tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t) + + parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32]) + res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL) + require.NoError(t, err) + require.NotEmpty(t, res.DeviceID, "應自建一筆 device") + require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token") + require.NotNil(t, res.SessionInfo) + + // 1) device 真的進 DB、owner 對齊 + dev, err := devRepo.Get(ctx, res.DeviceID) + require.NoError(t, err) + assert.Equal(t, owner, dev.OwnerUserID) + assert.Equal(t, defaultPairedDeviceName, dev.Name) + assert.Equal(t, defaultPairedDeviceType, dev.DeviceType) + assert.NotNil(t, dev.PairedAt, "自建 device 應設 paired_at") + + // 2) session token 真的進 DB、device_id 綁到新建 device(非空、FK 滿足) + tok, err := sessions.Get(ctx, res.SessionPlaintext) + require.NoError(t, err) + assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 的 device_id 應綁到自建 device") + assert.Equal(t, owner, tok.UserID) + assert.Equal(t, parentHash, tok.ParentTokenHash, "parent_token_hash 應為來源 pairing token hash(稽核鏈)") + + // 3) 直接查 DB 確認 session_tokens.device_id 非 NULL + var deviceIDIsNull bool + require.NoError(t, tdb.Pool.QueryRow(ctx, + `SELECT device_id IS NULL FROM session_tokens WHERE token_hash = $1`, + auth.HashToken(res.SessionPlaintext)).Scan(&deviceIDIsNull)) + assert.False(t, deviceIDIsNull, "session_tokens.device_id 不應為 NULL") +} + +// TestPGExchange_Provision_RollbackOnBadOwner 驗證原子性:owner 不存在於 users(FK violation) +// → device INSERT 撞 owner_user_id FK → 整筆 rollback,device 不殘留。 +func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) { + ctx := context.Background() + tdb, exchanger, _, _, _ := pgExchangeFixture(t) + + badOwner := uuid.NewString() // 不在 users 表 + _, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL) + require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation") + + // device 不應殘留(整筆交易 rollback) + assert.Equal(t, 0, tdb.CountRows(t, "devices"), "FK 失敗應 rollback,無 device 殘留") + assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "session token 也不應建立") +} + +// TestPGExchange_Provision_MultipleCreatesDistinctDevices 驗證多次 exchange 各自建新 device +// (不同 UUID)——對齊「不同次配對視為不同 agent 連線」的冪等語意。 +func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) { + ctx := context.Background() + _, exchanger, _, _, owner := pgExchangeFixture(t) + + res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL) + require.NoError(t, err) + res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL) + require.NoError(t, err) + + assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device") + assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同") +} diff --git a/visionA-backend/internal/api/pairing_exchange_test.go b/visionA-backend/internal/api/pairing_exchange_test.go new file mode 100644 index 0000000..094ef12 --- /dev/null +++ b/visionA-backend/internal/api/pairing_exchange_test.go @@ -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) +} diff --git a/visionA-backend/internal/api/storage_test.go b/visionA-backend/internal/api/storage_test.go new file mode 100644 index 0000000..200e986 --- /dev/null +++ b/visionA-backend/internal/api/storage_test.go @@ -0,0 +1,307 @@ +// storage_test.go — /storage/* presigned 代理 handler 的 unit test。 +// +// Owner: testing agent(補測任務 — internal/api 弱處 handler) +// +// 測試對象:storage.go 的 storageGetHandler / storagePutHandler / verifyStorageSignature / +// storageKeyFromPath / registerStorageRoutes。 +// +// 策略:用真 *storage.LocalFSStore(t.TempDir() 後端、unit 性質、不需 docker)+ httptest 打 handler。 +// 簽章用 store 的 PresignedGetURL / PresignedPutURL 產生(與生產同一條簽章邏輯), +// 再從產出的 URL 拆出 expires / signature 組請求 — 避免測試自己重刻 HMAC(會脆弱)。 +package api + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/storage" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// newStorageTestServer 建立一個只掛 /storage/* 路由的 gin engine(HMAC 控管、不經 AuthMiddleware)。 +func newStorageTestServer(t *testing.T, deps Deps) *gin.Engine { + t.Helper() + r := gin.New() + registerStorageRoutes(r, deps) + return r +} + +// newLocalStore 建立一個 t.TempDir() 後端、固定 secret 的 LocalFSStore。 +func newLocalStore(t *testing.T) *storage.LocalFSStore { + t.Helper() + s, err := storage.NewLocalFSStore(t.TempDir(), "http://localhost:3721/storage", "storage-test-secret") + require.NoError(t, err) + return s +} + +// signedQuery 用 store 的 presigned 邏輯產生 method 對應的 (expires, signature) query 字串。 +// 回傳形如 "expires=...&signature=..." 的 raw query(不含前導 ?)。 +func signedQuery(t *testing.T, s *storage.LocalFSStore, method, key string, ttl time.Duration) string { + t.Helper() + var raw string + var err error + switch method { + case http.MethodGet: + raw, err = s.PresignedGetURL(context.Background(), key, ttl) + case http.MethodPut: + raw, err = s.PresignedPutURL(context.Background(), key, ttl) + default: + t.Fatalf("unsupported method %q", method) + } + require.NoError(t, err) + u, err := url.Parse(raw) + require.NoError(t, err) + q := u.Query() + // 只保留 expires / signature(mode 不影響 handler 驗簽)。 + out := url.Values{} + out.Set("expires", q.Get("expires")) + out.Set("signature", q.Get("signature")) + return out.Encode() +} + +// ───────────────────────── storageKeyFromPath(純函式)───────────────────────── + +func TestStorageKeyFromPath(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want string + }{ + {"happy: 去掉前導斜線", "/models/u1/a.nef", "models/u1/a.nef"}, + {"empty: 空字串", "", ""}, + {"boundary: 只有一個斜線", "/", ""}, + {"無前導斜線時原樣回傳", "models/a.nef", "models/a.nef"}, + {"巢狀深路徑", "/a/b/c/d.bin", "a/b/c/d.bin"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, storageKeyFromPath(tc.in)) + }) + } +} + +// ───────────────────────── registerStorageRoutes ───────────────────────── + +// Storage 為 nil 時不註冊任何 /storage/* 路由(handler 不存在 → gin 回 404)。 +func TestRegisterStorageRoutes_NilStorage_NoRoutes(t *testing.T) { + t.Parallel() + r := newStorageTestServer(t, Deps{Storage: nil}) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/storage/models/x.nef?expires=1&signature=abc", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code, + "Storage 為 nil 時不應註冊路由,gin 對未知路由回 404") +} + +// ───────────────────────── PUT happy + GET happy(round-trip)───────────────────────── + +// 完整 round-trip:簽章 PUT 寫入 → 簽章 GET 讀回,內容一致。 +func TestStoragePut_Then_Get_RoundTrip(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + key := "models/user-1/round.nef" + payload := []byte("visiona-round-trip-bytes") + + // PUT + putW := httptest.NewRecorder() + putReq := httptest.NewRequest(http.MethodPut, + "/storage/"+key+"?"+signedQuery(t, s, http.MethodPut, key, time.Hour), + bytes.NewReader(payload)) + putReq.ContentLength = int64(len(payload)) + r.ServeHTTP(putW, putReq) + require.Equal(t, http.StatusNoContent, putW.Code, "簽章正確的 PUT 應回 204") + + // 底層 storage 確實有寫入 + gotObj, _, err := s.Get(context.Background(), key) + require.NoError(t, err) + _ = gotObj.Close() + + // GET + getW := httptest.NewRecorder() + getReq := httptest.NewRequest(http.MethodGet, + "/storage/"+key+"?"+signedQuery(t, s, http.MethodGet, key, time.Hour), nil) + r.ServeHTTP(getW, getReq) + + require.Equal(t, http.StatusOK, getW.Code, "簽章正確的 GET 應回 200") + assert.Equal(t, payload, getW.Body.Bytes(), "GET 內容應與 PUT 寫入一致") + assert.Equal(t, "application/octet-stream", getW.Header().Get("Content-Type")) + assert.Equal(t, "24", getW.Header().Get("Content-Length")) +} + +// ───────────────────────── GET 錯誤路徑 ───────────────────────── + +// GET 缺 signature/expires → 403 INVALID_SIGNATURE。 +func TestStorageGet_MissingSignature_403(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + + w := httptest.NewRecorder() + // 沒有任何 query 參數 + req := httptest.NewRequest(http.MethodGet, "/storage/models/u1/a.nef", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature) +} + +// GET 簽章被竄改 → 403。 +func TestStorageGet_TamperedSignature_403(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + key := "models/u1/a.nef" + + q := signedQuery(t, s, http.MethodGet, key, time.Hour) + tampered := strings.Replace(q, "signature=", "signature=ZZZ", 1) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/storage/"+key+"?"+tampered, nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature) +} + +// GET 簽章已過期(boundary:負 ttl → expires 在過去)→ 403。 +func TestStorageGet_ExpiredSignature_403(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + key := "models/u1/a.nef" + + // ttl 為負 → presignedURL 產出的 expires 在過去 → VerifySignature 走過期分支 + q := signedQuery(t, s, http.MethodGet, key, -time.Hour) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/storage/"+key+"?"+q, nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature) +} + +// GET 簽章正確但 object 不存在 → 404 NOT_FOUND。 +func TestStorageGet_NotFound_404(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + key := "models/u1/missing.nef" // 從未 Put 過 + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/storage/"+key+"?"+signedQuery(t, s, http.MethodGet, key, time.Hour), nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeNotFound) +} + +// expires 非數字 → verifyStorageSignature 在 ParseInt 失敗 → 403。 +func TestStorageGet_NonNumericExpires_403(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/storage/models/u1/a.nef?expires=not-a-number&signature=abc", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature) +} + +// ───────────────────────── PUT 錯誤路徑 ───────────────────────── + +// PUT 缺簽章 → 403,且不應寫入底層 storage。 +func TestStoragePut_MissingSignature_403_NoWrite(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + key := "models/u1/nowrite.nef" + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/storage/"+key, + bytes.NewReader([]byte("should-not-persist"))) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature) + + // 驗簽失敗 → 不應寫入 + _, _, err := s.Get(context.Background(), key) + assert.ErrorIs(t, err, storage.ErrNotFound, "驗簽失敗的 PUT 不應寫入 storage") +} + +// PUT 用 GET 方法的簽章(method mismatch)→ 403(簽章把 method 綁進 payload)。 +func TestStoragePut_WrongMethodSignature_403(t *testing.T) { + s := newLocalStore(t) + r := newStorageTestServer(t, Deps{Storage: s}) + key := "models/u1/methodmix.nef" + + // 用 GET 簽章去打 PUT + q := signedQuery(t, s, http.MethodGet, key, time.Hour) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/storage/"+key+"?"+q, + bytes.NewReader([]byte("x"))) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, + "GET 簽章不能用於 PUT(method 綁進簽章 payload)") +} + +// ───────────────────────── verifyStorageSignature: 非 LocalFS backend ───────────────────────── + +// fakeNonLocalStore 是一個非 *LocalFSStore 的 storage.Store 實作, +// 用來驗 verifyStorageSignature 對「非 LocalFS backend」直接回 ErrInvalidSignature。 +type fakeNonLocalStore struct{} + +func (fakeNonLocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error { + return nil +} +func (fakeNonLocalStore) Get(ctx context.Context, key string) (io.ReadCloser, *storage.Object, error) { + return nil, nil, storage.ErrNotFound +} +func (fakeNonLocalStore) Stat(ctx context.Context, key string) (*storage.Object, error) { + return nil, storage.ErrNotFound +} +func (fakeNonLocalStore) Exists(ctx context.Context, key string) (bool, error) { return false, nil } +func (fakeNonLocalStore) Delete(ctx context.Context, key string) error { return nil } +func (fakeNonLocalStore) List(ctx context.Context, prefix string) ([]*storage.Object, error) { + return nil, nil +} +func (fakeNonLocalStore) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + return "", nil +} +func (fakeNonLocalStore) PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + return "", nil +} + +// 非 LocalFS backend:即使帶 query,verifyStorageSignature type-assert 失敗 → 403。 +func TestStorageGet_NonLocalFSBackend_403(t *testing.T) { + r := newStorageTestServer(t, Deps{Storage: fakeNonLocalStore{}}) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, + "/storage/models/u1/a.nef?expires=99999999999&signature=whatever", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, + "非 *LocalFSStore backend 不應通過 /storage 驗簽") + assert.Contains(t, w.Body.String(), ErrCodeInvalidSignature) +} diff --git a/visionA-backend/internal/auth/postgres_session_token_store.go b/visionA-backend/internal/auth/postgres_session_token_store.go index d14d902..e5be785 100644 --- a/visionA-backend/internal/auth/postgres_session_token_store.go +++ b/visionA-backend/internal/auth/postgres_session_token_store.go @@ -57,6 +57,17 @@ const sessionColumns = `token_hash, user_id, device_id, parent_token_hash, // 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。 func (s *PostgresSessionTokenStore) Create( ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration, +) (string, *SessionToken, error) { + return s.CreateTx(ctx, s.pool, userID, deviceID, parentTokenHash, ttl) +} + +// CreateTx 與 Create 相同的語意,但在傳入的 Querier(pool 或 tx)上執行。 +// +// 用於 pairing exchange 自建 device 時,與 device 建立在同一交易內(pairing 收尾問題 #2): +// 「建 device + 建 session token」整筆原子。q 可為 *pgxpool.Pool(自動 commit)或 pgx.Tx +// (隨外層交易)。device_id NOT NULL(session token 必綁 device),caller 須先在同 tx 建好 device。 +func (s *PostgresSessionTokenStore) CreateTx( + ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration, ) (string, *SessionToken, error) { plaintext, err := GenerateSessionToken() if err != nil { @@ -86,10 +97,10 @@ func (s *PostgresSessionTokenStore) Create( parentArg = parentTokenHash } - const q = `INSERT INTO session_tokens + const sql = `INSERT INTO session_tokens (token_hash, user_id, device_id, parent_token_hash, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6)` - if _, err := s.pool.Exec(ctx, q, + if _, err := q.Exec(ctx, sql, info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt, ); err != nil { return "", nil, fmt.Errorf("auth: pg session Create: %w", err) diff --git a/visionA-backend/internal/auth/postgres_session_token_store_db_test.go b/visionA-backend/internal/auth/postgres_session_token_store_db_test.go index 30fd9e7..99380e5 100644 --- a/visionA-backend/internal/auth/postgres_session_token_store_db_test.go +++ b/visionA-backend/internal/auth/postgres_session_token_store_db_test.go @@ -304,10 +304,16 @@ func TestPGSession_ContextCancel(t *testing.T) { assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error") } -// tdbCountActive 計算某 owner 仍未過期(CleanupExpired 後殘留)的 session token 數。 -// 用 CleanupExpired 後再跑一次 0-removed 的方式間接驗證殘留數。 +// tdbCountActive 直接 SELECT count(*) 該 owner 在 session_tokens 表中殘留的列數。 +// +// 呼叫端固定在 CleanupExpired 之後使用:過期列已被刪除,故殘留列數即「未過期列數」。 +// 這是直接查 DB 殘留量,不另跑 CleanupExpired。 +// +// 參數 deviceID 目前未用於查詢(殘留量以 owner 為準即可),保留於簽章以利日後若需 +// 收斂到單一 device 時擴充查詢,不更動呼叫端。 func tdbCountActive(ctx context.Context, t *testing.T, s *PostgresSessionTokenStore, owner, deviceID string) int { t.Helper() + _ = deviceID // 見上方註解:目前以 owner 為計數範圍。 var n int err := s.pool.QueryRow(ctx, `SELECT count(*) FROM session_tokens WHERE user_id = $1`, owner).Scan(&n) diff --git a/visionA-backend/internal/conversion/converter_client.go b/visionA-backend/internal/conversion/converter_client.go index 746f709..a70038d 100644 --- a/visionA-backend/internal/conversion/converter_client.go +++ b/visionA-backend/internal/conversion/converter_client.go @@ -153,18 +153,35 @@ type PromoteReq struct { // 注意:這是 client 層的中間 type,flow.go 會轉成 conversion.Job(對 frontend 的 shape)。 type ConverterJob struct { JobID string - Status string // "created" / "running" / "completed" / "failed" - Stage string // "onnx" / "bie" / "nef";completed 時 converter 回 null → "" - Progress *int // 整體 0-100;可能為 nil(converter 沒給) - StageProgress *int // 當前 stage 0-100;可能為 nil - SourceFilename string // 取自 input.filename - Platform string // 取自 parameters.platform + Status string // "created" / "running" / "completed" / "failed" + Stage string // "onnx" / "bie" / "nef";completed 時 converter 回 null → "" + Progress *int // 整體 0-100;可能為 nil(converter 沒給) + StageProgress *int // 當前 stage 0-100;可能為 nil + SourceFilename string // 取自 input.filename + Platform string // 取自 parameters.platform CreatedAt time.Time UpdatedAt time.Time ExpiresAt time.Time // converter 沒給時上層自行 created_at + 7d 推算 ErrorCode string // 取自 error.code ErrorMessage string // 取自 error.message TargetObjectKey string // 僅 promote 後才有;GET / list 時為 "" + + // ── 模型 metadata(B4,optional / 防禦性)───────────────────────────────── + // + // 來源:轉檔服務端 bie worker 的 analysis_info(services/workers/bie/core.py)。 + // 轉檔完成(status=completed)時,converter 端**預期**會把 analysis_info 串進 + // `GET /api/v1/jobs/{id}` 的 response(見下方 converterJobJSON.AnalysisInfo 假設的 schema)。 + // + // **這幾個欄位全部 optional**:轉檔端尚未串好 analysis_info 時(目前狀態), + // 這些欄位維持零值(nil / ""),不影響建 model(InputShape 留空即可)。 + // + // 轉檔端 result schema 對齊點(待轉檔端交接檔確認):見 converterJobJSON.AnalysisInfo。 + // + // InputShape 對映:bie worker 給的 {batch_size, channels, height, width} + // → []int{batch, channel, height, width}(NCHW 順序,對齊 Model.InputShape 與 PG INT[] 既有測試 [1,3,224,224])。 + InputShape []int // e.g. [1, 3, 224, 224](NCHW);轉檔端沒給 → nil + Classes []string // 類別標籤;轉檔端沒給 → nil + Framework string // 原始框架("onnx" / "tflite" / ...);轉檔端沒給 → "" } // ConverterPromoteResult 是 Promote 的 response shape。 @@ -675,7 +692,8 @@ func (c *converterClient) mapListJobsError(status int, body []byte) error { // - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同 // // reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure -// — request body 可能在 retry 時已被讀完,必須重建。caller 內部用 bytes.NewReader 等可重建的 body。 +// +// — request body 可能在 retry 時已被讀完,必須重建。caller 內部用 bytes.NewReader 等可重建的 body。 func (c *converterClient) doWithRetry( ctx context.Context, endpointKind, label string, @@ -894,13 +912,14 @@ func extractActiveJobFromDetails(raw json.RawMessage) *Job { // 為了同時支援: // - CreateJobResponse(POST /jobs 201)— 無 stage_progress / input.filename 等欄位 // - Job(GET /jobs/{id})— 完整欄位 +// // 全部欄位都用 pointer 或 nullable,Marshal 時靠下方 toConverterJob 統一轉。 type converterJobJSON struct { - JobID string `json:"job_id"` - Status string `json:"status"` - Stage *string `json:"stage"` // completed 時 converter 回 null - Progress *int `json:"progress"` - StageProgress *int `json:"stage_progress"` + JobID string `json:"job_id"` + Status string `json:"status"` + Stage *string `json:"stage"` // completed 時 converter 回 null + Progress *int `json:"progress"` + StageProgress *int `json:"stage_progress"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ExpiresAt time.Time `json:"expires_at"` @@ -915,6 +934,67 @@ type converterJobJSON struct { Message string `json:"message"` Stage string `json:"stage"` } `json:"error"` + + // AnalysisInfo 是 B4 模型 metadata 的**假設 schema**(轉檔端尚未定案)。 + // + // ⚠️ 轉檔端對齊點(會寫進交接檔,轉檔端做的時候依此實作或回頭調整): + // visionA 假設 converter 在 `GET /api/v1/jobs/{id}` 的 top-level 放一個 + // `analysis_info` 物件,形如: + // + // "analysis_info": { + // "input_shape": [1, 3, 224, 224], // 優先;NCHW 順序 + // "batch_size": 1, // 後備(input_shape 缺時用這 4 個組) + // "channels": 3, + // "height": 224, + // "width": 224, + // "classes": ["face", "person"], // optional + // "framework": "onnx" // optional + // } + // + // 來源是 bie worker 的 analysis_info(input_name/batch_size/channels/height/width)。 + // visionA 同時接受兩種 input_shape 表示法(防呆,看轉檔端最後給哪種): + // (a) 明確的 `input_shape` 陣列 → 直接用 + // (b) 拆開的 batch_size/channels/height/width → 組成 [batch, channel, height, width] + // + // **全部欄位 pointer / nullable**:轉檔端沒串好(目前狀態)→ AnalysisInfo 整個為 nil + // 或欄位為空 → 對映出的 metadata 全為零值,不報錯。 + AnalysisInfo *converterAnalysisInfoJSON `json:"analysis_info"` +} + +// converterAnalysisInfoJSON 是 B4 模型 metadata 的中介 unmarshal type。 +// +// 對齊 converterJobJSON.AnalysisInfo 註解的假設 schema(轉檔端待對齊)。 +// 全欄位 pointer / slice — 任一缺漏都不影響解析(缺 → 零值)。 +type converterAnalysisInfoJSON struct { + InputShape []int `json:"input_shape"` // 優先表示法 + BatchSize *int `json:"batch_size"` // 後備:拆開的 4 維 + Channels *int `json:"channels"` + Height *int `json:"height"` + Width *int `json:"width"` + Classes []string `json:"classes"` + Framework string `json:"framework"` +} + +// toInputShape 把 analysis_info 對映成 Model.InputShape 用的 []int(NCHW)。 +// +// 優先序: +// 1. 明確的 input_shape 陣列(非空)→ 直接回傳 +// 2. 後備:batch_size/channels/height/width 四維**全部齊全**才組 +// [batch, channel, height, width];任一缺 → 回 nil(不亂組半套維度) +// 3. 都沒有 → nil(轉檔端尚未串好的目前狀態) +func (a *converterAnalysisInfoJSON) toInputShape() []int { + if a == nil { + return nil + } + if len(a.InputShape) > 0 { + out := make([]int, len(a.InputShape)) + copy(out, a.InputShape) + return out + } + if a.BatchSize != nil && a.Channels != nil && a.Height != nil && a.Width != nil { + return []int{*a.BatchSize, *a.Channels, *a.Height, *a.Width} + } + return nil } // parseConverterJob 解 GET /api/v1/jobs/{id} 或 POST /api/v1/jobs 201 的 response。 @@ -953,6 +1033,14 @@ func (jr *converterJobJSON) toConverterJob() *ConverterJob { cj.ErrorCode = jr.Error.Code cj.ErrorMessage = jr.Error.Message } + // B4 模型 metadata(optional / 防禦性):轉檔端沒串 analysis_info → 全留零值。 + if jr.AnalysisInfo != nil { + cj.InputShape = jr.AnalysisInfo.toInputShape() + if len(jr.AnalysisInfo.Classes) > 0 { + cj.Classes = append([]string(nil), jr.AnalysisInfo.Classes...) + } + cj.Framework = jr.AnalysisInfo.Framework + } return cj } @@ -1209,9 +1297,9 @@ func parseFilenameFromContentDisposition(cd string) string { func parseConverterPromoteResult(body []byte) (*ConverterPromoteResult, error) { var resp struct { Promoted []struct { - TargetObjectKey string `json:"target_object_key"` - SizeBytes int64 `json:"size_bytes"` - FileAccessAgentETag string `json:"file_access_agent_etag"` + TargetObjectKey string `json:"target_object_key"` + SizeBytes int64 `json:"size_bytes"` + FileAccessAgentETag string `json:"file_access_agent_etag"` } `json:"promoted"` } if err := json.Unmarshal(body, &resp); err != nil { diff --git a/visionA-backend/internal/conversion/converter_client_test.go b/visionA-backend/internal/conversion/converter_client_test.go index b181bf0..289d1eb 100644 --- a/visionA-backend/internal/conversion/converter_client_test.go +++ b/visionA-backend/internal/conversion/converter_client_test.go @@ -1274,11 +1274,11 @@ func TestGetResult_EmptyJobID(t *testing.T) { // TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。 // // v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case): -// - RFC 5987 encoded form(`filename*=UTF-8''...`)— 驗 Go stdlib `mime.ParseMediaType` +// - RFC 5987 encoded form(`filename*=UTF-8”...`)— 驗 Go stdlib `mime.ParseMediaType` // 對 charset-encoded `filename*` 參數的 transparent 解碼行為 // - Hostile-input:CRLF injection / path traversal / null byte / extreme length // -// **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8''...` 形式 +// **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8”...` 形式 // **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。 // 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded // (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。 @@ -1339,6 +1339,149 @@ func TestParseFilenameFromContentDisposition(t *testing.T) { } } +// ========================================================================== +// B4:analysis_info → ConverterJob metadata 對映(parse 層) +// ========================================================================== + +// TestParseConverterJob_AnalysisInfo_InputShape:GET /jobs/{id} response 帶 analysis_info +// 含明確 input_shape 陣列 → ConverterJob.InputShape / Classes / Framework 正確對映。 +// +// 對齊 converterJobJSON.AnalysisInfo 假設 schema:input_shape 為 NCHW [1,3,224,224]。 +func TestParseConverterJob_AnalysisInfo_InputShape(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "job_id": "j-meta-1", + "status": "completed", + "stage": null, + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "input": {"filename": "yolov5s.onnx"}, + "parameters": {"platform": "720"}, + "analysis_info": { + "input_shape": [1, 3, 224, 224], + "classes": ["face", "person"], + "framework": "onnx" + } + }`) + + cj, err := parseConverterJob(body) + require.NoError(t, err) + require.NotNil(t, cj) + assert.Equal(t, []int{1, 3, 224, 224}, cj.InputShape, + "明確 input_shape 陣列應直接對映(NCHW,對齊 PG INT[] 既有測試 [1,3,224,224])") + assert.Equal(t, []string{"face", "person"}, cj.Classes) + assert.Equal(t, "onnx", cj.Framework) +} + +// TestParseConverterJob_AnalysisInfo_DimsFallback:analysis_info 沒給明確 input_shape, +// 但給拆開的 batch_size/channels/height/width 四維 → 組成 [batch, channel, height, width](NCHW)。 +// +// 對映 bie worker 原生輸出(input_name/batch_size/channels/height/width)。 +func TestParseConverterJob_AnalysisInfo_DimsFallback(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "job_id": "j-meta-2", + "status": "completed", + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "analysis_info": { + "batch_size": 1, + "channels": 3, + "height": 640, + "width": 480, + "framework": "tflite" + } + }`) + + cj, err := parseConverterJob(body) + require.NoError(t, err) + require.NotNil(t, cj) + assert.Equal(t, []int{1, 3, 640, 480}, cj.InputShape, + "拆開的四維應組成 NCHW [batch, channel, height, width]") + assert.Equal(t, "tflite", cj.Framework) + assert.Nil(t, cj.Classes, "未給 classes → nil") +} + +// TestParseConverterJob_AnalysisInfo_PartialDims_NoGuess:四維只給一部分(缺 width)→ +// 不亂組半套維度,InputShape 留 nil(防呆)。 +func TestParseConverterJob_AnalysisInfo_PartialDims_NoGuess(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "job_id": "j-meta-3", + "status": "completed", + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "analysis_info": { + "batch_size": 1, + "channels": 3, + "height": 224 + } + }`) + + cj, err := parseConverterJob(body) + require.NoError(t, err) + require.NotNil(t, cj) + assert.Nil(t, cj.InputShape, "四維缺一(缺 width)→ 不亂組,InputShape 留 nil") +} + +// TestParseConverterJob_AnalysisInfo_Missing:完全沒有 analysis_info(轉檔端尚未串好的目前狀態) +// → metadata 全留零值、不報錯、不 panic。這是 B4 防禦性的核心 case。 +func TestParseConverterJob_AnalysisInfo_Missing(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "job_id": "j-meta-4", + "status": "completed", + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "input": {"filename": "model.onnx"}, + "parameters": {"platform": "520"} + }`) + + cj, err := parseConverterJob(body) + require.NoError(t, err, "缺 analysis_info 不應報錯(轉檔端尚未串好)") + require.NotNil(t, cj) + assert.Nil(t, cj.InputShape, "缺 analysis_info → InputShape 留 nil") + assert.Nil(t, cj.Classes) + assert.Empty(t, cj.Framework) + // 其餘欄位照常解析、不受影響 + assert.Equal(t, "completed", cj.Status) + assert.Equal(t, "model.onnx", cj.SourceFilename) +} + +// TestParseConverterJob_AnalysisInfo_EmptyObject:analysis_info 物件存在但所有欄位皆缺 +// → 同樣全零值、不報錯(防禦:轉檔端送了空殼物件)。 +func TestParseConverterJob_AnalysisInfo_EmptyObject(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "job_id": "j-meta-5", + "status": "completed", + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "analysis_info": {} + }`) + + cj, err := parseConverterJob(body) + require.NoError(t, err) + require.NotNil(t, cj) + assert.Nil(t, cj.InputShape, "analysis_info 空物件 → InputShape 留 nil") + assert.Nil(t, cj.Classes) + assert.Empty(t, cj.Framework) +} + +// TestConverterAnalysisInfoJSON_ToInputShape_NilReceiver:nil *converterAnalysisInfoJSON +// 呼叫 toInputShape 不 panic、回 nil(nil-safe 保證)。 +func TestConverterAnalysisInfoJSON_ToInputShape_NilReceiver(t *testing.T) { + t.Parallel() + + var a *converterAnalysisInfoJSON + assert.Nil(t, a.toInputShape(), "nil receiver 應回 nil、不 panic") +} + // ========================================================================== // 共用:interface 契約 + helpers // ========================================================================== diff --git a/visionA-backend/internal/conversion/flow.go b/visionA-backend/internal/conversion/flow.go index c141592..879f4e5 100644 --- a/visionA-backend/internal/conversion/flow.go +++ b/visionA-backend/internal/conversion/flow.go @@ -82,8 +82,14 @@ type ModelRecord struct { FileSize int64 FileChecksum string TargetChip string - Source string // 永遠 "converted" - SourceJobID string + // ── 模型 metadata(B4,optional)───────────────────────────────────────── + // 來源:converter job 的 analysis_info(轉檔端 bie worker)。轉檔端尚未串好時 + // 全留零值(nil / ""),adapter 對映到 model.Model 後一樣留空、不影響建 model。 + InputShape []int // e.g. [1, 3, 224, 224](NCHW);轉檔端沒給 → nil + Classes []string // 類別標籤;轉檔端沒給 → nil + Framework string // 原始框架;轉檔端沒給 → "" + Source string // 永遠 "converted" + SourceJobID string // FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。 // = converter promote 的 target_object_key(buildTargetObjectKey:models/{userID}/{jobID}.nef)。 // PromoteToModels 寫入;adapter 對映到 model.Model.FAAObjectKey。 @@ -223,16 +229,16 @@ var _ Service = (*flow)(nil) // 5. 寫 ownership.Set(jobID, userID) // 6. 失敗時的 cleanup 行為(§4.3.2): // - converter Phase 1 **沒有實作** `POST /api/v1/jobs/{id}/cancel` endpoint -// (已驗證:apps/task-scheduler 的 routes/v1/jobs.js 只有 POST '/'、GET '/'、 -// GET '/:id'、POST '/:id/download-tokens'、DELETE '/:id')。 +// (已驗證:apps/task-scheduler 的 routes/v1/jobs.js 只有 POST '/'、GET '/'、 +// GET '/:id'、POST '/:id/download-tokens'、DELETE '/:id')。 // - Phase 0.8 採「socket close 自然 abort」策略:streaming body 中斷時 -// converter multer 拋錯 → 該 job 留 `failed` 狀態 + error_code=invalid_multipart -// → converter 對 active_job 邏輯視為已結束 → 下次 init 不會撞 409。 +// converter multer 拋錯 → 該 job 留 `failed` 狀態 + error_code=invalid_multipart +// → converter 對 active_job 邏輯視為已結束 → 下次 init 不會撞 409。 // - flow.go 不主動發 cancel(沒有對應 endpoint 可發);只在 InitJob 失敗時 log。 // - **Phase 1+ 升級**:當 converter 補上 `/cancel` 後,T3 ConverterClient -// 新增 `CancelJob(ctx, jobID) error`,flow.go 在 InitJob 失敗時開獨立 5s -// timeout context(不繼承已 cancel 的 ctx)做 best-effort 主動 cancel。 -// 見 conversion.md §4.3.2 + ./05-implementation/phase-0.8-T6.md follow-ups。 +// 新增 `CancelJob(ctx, jobID) error`,flow.go 在 InitJob 失敗時開獨立 5s +// timeout context(不繼承已 cancel 的 ctx)做 best-effort 主動 cancel。 +// 見 conversion.md §4.3.2 + ./05-implementation/phase-0.8-T6.md follow-ups。 func (f *flow) InitJob(ctx context.Context, in InitJobInput) (*Job, error) { if in.UserID == "" { return nil, errors.New("conversion: InitJob requires UserID") @@ -673,6 +679,12 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string) FileSize: promoteRes.Size, FileChecksum: promoteRes.Checksum, TargetChip: normalizeTargetChip(cj.Platform), + // B4 模型 metadata:從 converter job 的 analysis_info 接過來(cj 由上面 step 2 + // 的 GetJob 取得)。轉檔端尚未串好 analysis_info 時 cj.InputShape 等為零值 → + // 這裡也維持零值,不影響建 model(InputShape 留空)。 + InputShape: cj.InputShape, + Classes: cj.Classes, + Framework: cj.Framework, Source: "converted", SourceJobID: jobID, FAAObjectKey: faaObjectKey, @@ -713,12 +725,12 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string) // 2. converter.GetJob — 確認 status=completed(否則 ErrJobNotCompleted) // 3. ensurePromoted — 自動觸發 promote 確保 converter MinIO 內有 NEF // - 設計選擇(沿用 Phase 0.8):自動觸發。理由:api-conversion.md §4 註解說 -// 「兩條路徑(promote-to-models / download)都拿同一個 target_object_key」+ -// 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted(冪等)」— -// 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。 +// 「兩條路徑(promote-to-models / download)都拿同一個 target_object_key」+ +// 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted(冪等)」— +// 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。 // - v0.6 同時保留此步驟的另一個理由:converter `GET /api/v1/jobs/{id}/result` 從 -// converter MinIO get object;promote 是把 NEF 同步保留在 MinIO + 推到 FAA 的步驟, -// 兩者順序固定(promote 先、GetResult 後)。 +// converter MinIO get object;promote 是把 NEF 同步保留在 MinIO + 推到 FAA 的步驟, +// 兩者順序固定(promote 先、GetResult 後)。 // 4. converter.GetResult(jobID) — 從 converter MinIO streaming pull NEF binary // (v0.6 取代原 faa.GetFile(targetObjectKey) — visionA 端不再直接打 FAA) // 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil);caller(handler)負責 io.Copy 到 client + Close @@ -841,7 +853,8 @@ func defaultDownloadFilename(cj *ConverterJob) string { // // 用 modelStore.FindBySourceJobID 當 source-of-truth:若已有 model record 表示 // 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 端對同一 // job 重複 promote 接受」)— 直接打 converter,重複呼叫成本低(同步等 1-2s)。 diff --git a/visionA-backend/internal/conversion/flow_test.go b/visionA-backend/internal/conversion/flow_test.go index 4c12fac..177ec00 100644 --- a/visionA-backend/internal/conversion/flow_test.go +++ b/visionA-backend/internal/conversion/flow_test.go @@ -15,11 +15,16 @@ // // Phase 0.8 conversion (見 docs/autoflow/04-architecture/conversion.md §2.7) // Phase 0.8b T4:DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken -// (見 ADR-015 §6 + conversion.md §3 / §4.1) +// +// (見 ADR-015 §6 + conversion.md §3 / §4.1) +// // Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult -// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6) +// +// (見 ADR-016 + conversion.md §2.5 / §4.1 / §6) +// // Phase 0.8b v0.6 T3:flowStubFAA + flowFixture.faa 欄位整段砍除(ADR-016 撤回 FAA 直連、 -// faa_client.go 整檔刪除);FlowOpts.FAA 必填校驗一併移除。 +// +// faa_client.go 整檔刪除);FlowOpts.FAA 必填校驗一併移除。 package conversion import ( @@ -860,6 +865,71 @@ func TestPromoteToModels_HappyPath(t *testing.T) { // assertion 雙重防護(conversion_e2e_test.go:TestConversionE2E_DownloadStream) } +// TestPromoteToModels_Metadata_WrittenToRecord:B4 — converter job 帶 analysis_info +// 對映出的 InputShape / Classes / Framework,PromoteToModels 建 model record 時應寫進去。 +// +// 驗完整鏈路在 flow 層的最後一段:ConverterJob.InputShape → ModelRecord.InputShape。 +func TestPromoteToModels_Metadata_WrittenToRecord(t *testing.T) { + t.Parallel() + fix := newFlowFixture(t) + + fix.converter.setJob(&ConverterJob{ + JobID: "j-meta", + Status: "completed", + CreatedAt: time.Now().UTC(), + SourceFilename: "yolov5s.onnx", + Platform: "720", + // B4 metadata(模擬轉檔端已串好 analysis_info) + InputShape: []int{1, 3, 224, 224}, + Classes: []string{"face", "person"}, + Framework: "onnx", + }) + fix.ownership.Set("j-meta", "user-alice") + + res, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j-meta", "my-model") + require.NoError(t, err) + require.NotNil(t, res) + + // 驗 model record 寫入了 metadata(從 stub store 撈回比對) + fix.models.mu.Lock() + rec := fix.models.records[res.ModelID] + fix.models.mu.Unlock() + require.NotNil(t, rec) + assert.Equal(t, []int{1, 3, 224, 224}, rec.InputShape, + "PromoteToModels 應把 ConverterJob.InputShape 寫進 ModelRecord(NCHW)") + assert.Equal(t, []string{"face", "person"}, rec.Classes) + assert.Equal(t, "onnx", rec.Framework) +} + +// TestPromoteToModels_Metadata_Absent_StillSucceeds:B4 防禦性 — converter job 沒帶 +// analysis_info(轉檔端尚未串好)→ 建 model 照常成功、metadata 留零值、不報錯。 +func TestPromoteToModels_Metadata_Absent_StillSucceeds(t *testing.T) { + t.Parallel() + fix := newFlowFixture(t) + + fix.converter.setJob(&ConverterJob{ + JobID: "j-nometa", + Status: "completed", + CreatedAt: time.Now().UTC(), + SourceFilename: "model.onnx", + Platform: "520", + // 刻意不設 InputShape / Classes / Framework(轉檔端尚未串好) + }) + fix.ownership.Set("j-nometa", "user-bob") + + res, err := fix.svc.PromoteToModels(context.Background(), "user-bob", "j-nometa", "") + require.NoError(t, err, "缺 metadata 不應影響建 model") + require.NotNil(t, res) + + fix.models.mu.Lock() + rec := fix.models.records[res.ModelID] + fix.models.mu.Unlock() + require.NotNil(t, rec) + assert.Nil(t, rec.InputShape, "缺 analysis_info → InputShape 留 nil") + assert.Nil(t, rec.Classes) + assert.Empty(t, rec.Framework) +} + // TestPromoteToModels_DefaultName:caller 傳空 name 應走 fallback `_kl`。 func TestPromoteToModels_DefaultName(t *testing.T) { t.Parallel() diff --git a/visionA-backend/internal/db/redis_integration_test.go b/visionA-backend/internal/db/redis_integration_test.go new file mode 100644 index 0000000..90c19b0 --- /dev/null +++ b/visionA-backend/internal/db/redis_integration_test.go @@ -0,0 +1,151 @@ +//go:build dbtest + +// redis_integration_test.go — RedisClient 對「真 Redis」的整合測試。 +// +// owner: testing agent(internal/db redis 補測) +// +// build tag `dbtest`:預設 `go test ./...` 不編譯本檔。 +// 執行方式(二擇一,對齊 internal/usersession/redis_integration_test.go 既有 pattern): +// +// 1. 連既有 Redis(例如 130 上的 visiona-redis): +// VISIONA_TEST_REDIS_ADDR=visiona-redis:6379 go test -tags=dbtest ./internal/db/... +// +// 2. testcontainers 自動起一次性 Redis(需 Docker daemon;本機無 docker 時用 +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true 指向 130): +// go test -tags=dbtest ./internal/db/... +// +// 本檔與 redis_test.go(miniredis,預設可跑)互補:miniredis 驗連線/ping/close 程式邏輯, +// 本檔驗「NewRedisClient 對真 Redis 的 ping / graceful close / Client() 真的可用」。 +package db + +import ( + "context" + "net" + "os" + "strconv" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "visiona-backend/internal/config" +) + +// realRedisAddr 回傳一個可連的真 Redis host:port: +// - 若設了 VISIONA_TEST_REDIS_ADDR → 直接用(130 補跑路徑)。 +// - 否則 → testcontainers 起一次性 redis:7-alpine。 +func realRedisAddr(t *testing.T) string { + t.Helper() + + if addr := os.Getenv("VISIONA_TEST_REDIS_ADDR"); addr != "" { + return addr + } + + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: "redis:7-alpine", + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForListeningPort("6379/tcp").WithStartupTimeout(60 * time.Second), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start redis container: %v", err) + } + t.Cleanup(func() { _ = container.Terminate(ctx) }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("container host: %v", err) + } + port, err := container.MappedPort(ctx, "6379/tcp") + if err != nil { + t.Fatalf("container port: %v", err) + } + return host + ":" + port.Port() +} + +// realRedisConfig 把 host:port 拆回 RedisConfig 餵 NewRedisClient。 +func realRedisConfig(t *testing.T, addr string) config.RedisConfig { + t.Helper() + host, portStr, err := net.SplitHostPort(addr) + require.NoError(t, err, "解析 real redis addr=%s", addr) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + return config.RedisConfig{ + Host: host, + Port: port, + Password: os.Getenv("VISIONA_TEST_REDIS_PASSWORD"), + DB: 0, + ConnTimeout: 5 * time.Second, + } +} + +// TestRealRedis_NewClientConnectsAndPings 驗證 NewRedisClient 對真 Redis: +// 建連成功、啟動 ping 通過、Client() 可實際操作、Ping() 可重複呼叫。 +func TestRealRedis_NewClientConnectsAndPings(t *testing.T) { + cfg := realRedisConfig(t, realRedisAddr(t)) + + rc, err := NewRedisClient(context.Background(), cfg, nil) + require.NoError(t, err, "真 Redis 可達時應成功建連") + require.NotNil(t, rc) + t.Cleanup(rc.Close) + + // Client() 可實際操作真 Redis(SET/GET round-trip)。 + ctx := context.Background() + require.NoError(t, rc.Client().Set(ctx, "db_redis_it_key", "v1", time.Minute).Err()) + got, err := rc.Client().Get(ctx, "db_redis_it_key").Result() + require.NoError(t, err) + assert.Equal(t, "v1", got) + t.Cleanup(func() { _ = rc.Client().Del(context.Background(), "db_redis_it_key").Err() }) + + // Ping 可重複呼叫(health check 場景)。 + require.NoError(t, rc.Ping(ctx)) + require.NoError(t, rc.Ping(ctx)) +} + +// TestRealRedis_GracefulClose 驗證 Close 後底層 client 不可再用(graceful close 生效)。 +func TestRealRedis_GracefulClose(t *testing.T) { + cfg := realRedisConfig(t, realRedisAddr(t)) + rc, err := NewRedisClient(context.Background(), cfg, nil) + require.NoError(t, err) + + rc.Close() + // Close 後 Ping 應失敗(go-redis 回 "client is closed")。 + assert.Error(t, rc.Ping(context.Background()), "graceful close 後 Ping 應失敗") +} + +// TestRealRedis_WrongDBStillConnects 驗證非 0 的 DB index 也能正常建連(DB 欄位有被套用)。 +func TestRealRedis_WrongDBStillConnects(t *testing.T) { + cfg := realRedisConfig(t, realRedisAddr(t)) + cfg.DB = 1 // 切到 db1 + + rc, err := NewRedisClient(context.Background(), cfg, nil) + require.NoError(t, err) + t.Cleanup(rc.Close) + + // 在 db1 寫一筆,確認 SELECT 1 真的生效(不會誤落 db0)。 + ctx := context.Background() + require.NoError(t, rc.Client().Set(ctx, "db1_only_key", "x", time.Minute).Err()) + n, err := rc.Client().Exists(ctx, "db1_only_key").Result() + require.NoError(t, err) + assert.Equal(t, int64(1), n) + t.Cleanup(func() { _ = rc.Client().Del(context.Background(), "db1_only_key").Err() }) + + // 另開一個 db0 client,確認 db1 的 key 不可見(DB 隔離正確)。 + db0 := redis.NewClient(&redis.Options{ + Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), + Password: cfg.Password, + DB: 0, + }) + t.Cleanup(func() { _ = db0.Close() }) + n0, err := db0.Exists(ctx, "db1_only_key").Result() + require.NoError(t, err) + assert.Equal(t, int64(0), n0, "db1 的 key 不應在 db0 可見(DB 隔離)") +} diff --git a/visionA-backend/internal/db/redis_test.go b/visionA-backend/internal/db/redis_test.go new file mode 100644 index 0000000..bd3760c --- /dev/null +++ b/visionA-backend/internal/db/redis_test.go @@ -0,0 +1,187 @@ +// redis_test.go — RedisClient 的本機可跑測試(不需 docker)。 +// +// owner: testing agent(internal/db redis 補測) +// +// 分類: +// - SafeRedisTarget:純函式 unit(log 安全字串、不含密碼) +// - NewRedisClient / Ping / Close / Client:用 in-process miniredis(純 Go,不需 docker) +// 驗證連線建立、啟動 ping、graceful close、底層 client 取得。 +// - fail-fast:連不上的 host 應立即回 error(NewRedisClient 不靜默 fallback)。 +// +// 與 redis_integration_test.go(//go:build dbtest,真 Redis)互補: +// 本檔用 miniredis 驗「連線/ping/close 的程式邏輯」,dbtest 驗「真 Redis parity」。 +package db + +import ( + "context" + "net" + "strconv" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/config" +) + +// miniredisConfig 起一個 in-process miniredis 並回傳對應的 RedisConfig +// (把 mr.Addr() 的 host:port 拆回 Host / Port 欄位,餵給 NewRedisClient)。 +func miniredisConfig(t *testing.T) config.RedisConfig { + t.Helper() + mr := miniredis.RunT(t) // RunT 會在 t.Cleanup 自動 Close + + host, portStr, err := net.SplitHostPort(mr.Addr()) + require.NoError(t, err, "解析 miniredis addr") + port, err := strconv.Atoi(portStr) + require.NoError(t, err, "解析 miniredis port") + + return config.RedisConfig{ + Host: host, + Port: port, + DB: 0, + ConnTimeout: 2 * time.Second, + } +} + +// --------------------------------------------------------------------------- +// SafeRedisTarget — 純函式 unit(happy / 預設值 / 不含密碼) +// --------------------------------------------------------------------------- + +func TestSafeRedisTarget_FormatsHostPortDB(t *testing.T) { + got := SafeRedisTarget(config.RedisConfig{Host: "10.0.0.5", Port: 6380, DB: 3}) + assert.Equal(t, "10.0.0.5:6380/3", got) +} + +func TestSafeRedisTarget_DefaultsPortWhenZero(t *testing.T) { + // 邊界值:Port=0 應回退預設 6379(對齊 NewRedisClient 行為)。 + got := SafeRedisTarget(config.RedisConfig{Host: "redis", Port: 0, DB: 0}) + assert.Equal(t, "redis:6379/0", got) +} + +func TestSafeRedisTarget_NeverContainsPassword(t *testing.T) { + // 安全:即使 config 帶密碼,SafeRedisTarget 也不得洩漏。 + cfg := config.RedisConfig{Host: "redis", Port: 6379, DB: 0, Password: "super-secret-pw"} + got := SafeRedisTarget(cfg) + assert.NotContains(t, got, "super-secret-pw", "log 安全字串不得含密碼") + assert.Equal(t, "redis:6379/0", got) +} + +func TestSafeRedisTarget_EmptyHost(t *testing.T) { + // 空 Host(未啟用 Redis):仍回固定格式,不 panic。 + got := SafeRedisTarget(config.RedisConfig{}) + assert.Equal(t, ":6379/0", got) +} + +// --------------------------------------------------------------------------- +// NewRedisClient — happy path(miniredis) +// --------------------------------------------------------------------------- + +func TestNewRedisClient_ConnectsAndPings(t *testing.T) { + cfg := miniredisConfig(t) + + rc, err := NewRedisClient(context.Background(), cfg, nil) + require.NoError(t, err, "miniredis 可達時應成功建連並 ping 通過") + require.NotNil(t, rc) + t.Cleanup(rc.Close) + + // 底層 client 可取得且可用 + require.NotNil(t, rc.Client(), "Client() 應回傳非 nil 底層 client") + + // Ping 應通過 + require.NoError(t, rc.Ping(context.Background()), "已連線的 client Ping 應成功") +} + +func TestNewRedisClient_DefaultsPortWhenZero(t *testing.T) { + // Port=0 路徑:NewRedisClient 應回退 6379。 + // 這裡無法用 miniredis(它是隨機 port),改用「連不上的 host」驗證 + // fail-fast 與 SafeRedisTarget 中的預設 port 一致呈現。 + cfg := config.RedisConfig{ + Host: "192.0.2.1", // TEST-NET-1,保證不可路由 + Port: 0, + ConnTimeout: 300 * time.Millisecond, + } + _, err := NewRedisClient(context.Background(), cfg, nil) + require.Error(t, err, "連不上應 fail-fast") + assert.Contains(t, err.Error(), "192.0.2.1:6379/0", + "error 應含 SafeRedisTarget(含預設 port 6379)") +} + +// --------------------------------------------------------------------------- +// NewRedisClient — error path(fail-fast,不靜默 fallback) +// --------------------------------------------------------------------------- + +func TestNewRedisClient_UnreachableHostFailsFast(t *testing.T) { + cfg := config.RedisConfig{ + Host: "192.0.2.1", // TEST-NET-1(RFC 5737),保證 dial 超時 + Port: 6379, + ConnTimeout: 300 * time.Millisecond, // 短逾時讓測試快速失敗 + } + + start := time.Now() + rc, err := NewRedisClient(context.Background(), cfg, nil) + elapsed := time.Since(start) + + require.Error(t, err, "Redis 連不上時 NewRedisClient 必須回 error(fail-fast)") + assert.Nil(t, rc, "失敗時不應回傳半成品 client") + assert.Contains(t, err.Error(), "redis ping failed", "error 應標明 ping 失敗") + // fail-fast:不應 hang 太久(給予 ConnTimeout 數倍寬容,避免 CI 抖動 flaky) + assert.Less(t, elapsed, 5*time.Second, "fail-fast 不應 hang,實際耗時 %v", elapsed) +} + +func TestNewRedisClient_ContextCancelledFailsFast(t *testing.T) { + cfg := config.RedisConfig{ + Host: "192.0.2.1", + Port: 6379, + ConnTimeout: 5 * time.Second, + } + // 已取消的 context:ping 應立即失敗,不等滿 ConnTimeout。 + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + _, err := NewRedisClient(ctx, cfg, nil) + elapsed := time.Since(start) + + require.Error(t, err, "已取消的 ctx 下 NewRedisClient 應回 error") + assert.Less(t, elapsed, 2*time.Second, "已取消 ctx 不應等滿 ConnTimeout,實際 %v", elapsed) +} + +// --------------------------------------------------------------------------- +// Close — graceful + 重複呼叫安全 + nil receiver +// --------------------------------------------------------------------------- + +func TestRedisClient_Close_Idempotent(t *testing.T) { + cfg := miniredisConfig(t) + rc, err := NewRedisClient(context.Background(), cfg, nil) + require.NoError(t, err) + + // 重複呼叫 Close 不應 panic(doc 承諾「重複呼叫安全」)。 + assert.NotPanics(t, func() { + rc.Close() + rc.Close() + }) +} + +func TestRedisClient_Close_NilReceiverSafe(t *testing.T) { + // 邊界值:nil receiver Close 不應 panic(防呆,main.go 失敗路徑可能傳 nil)。 + var rc *RedisClient + assert.NotPanics(t, func() { rc.Close() }) +} + +// --------------------------------------------------------------------------- +// Ping — 已關閉 client 的行為 +// --------------------------------------------------------------------------- + +func TestRedisClient_Ping_AfterCloseReturnsError(t *testing.T) { + cfg := miniredisConfig(t) + rc, err := NewRedisClient(context.Background(), cfg, nil) + require.NoError(t, err) + + rc.Close() + + // Close 後底層 client 已關,Ping 應回 error(go-redis: client is closed)。 + err = rc.Ping(context.Background()) + assert.Error(t, err, "已關閉的 client Ping 應回 error") +} diff --git a/visionA-backend/internal/db/tx.go b/visionA-backend/internal/db/tx.go index 1be6c5c..8ac248e 100644 --- a/visionA-backend/internal/db/tx.go +++ b/visionA-backend/internal/db/tx.go @@ -80,6 +80,10 @@ func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) ( // 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。 // -// pgx.Tx 是 interface,無法直接取 (pgx.Tx)(nil) 當靜態斷言對象(nil interface 沒有具體型別), -// 故只對 *pgxpool.Pool 做編譯期斷言;pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。 -var _ Querier = (*pgxpool.Pool)(nil) +// 兩者都顯式斷言,避免任一方未來簽章漂移時只在 runtime 才爆。 +// pgx.Tx 本身是 interface,typed-nil((pgx.Tx)(nil))可作為靜態斷言對象——編譯器只看 +// 靜態型別是否滿足 Querier、不解參考該 nil 值,故安全。 +var ( + _ Querier = (*pgxpool.Pool)(nil) + _ Querier = (pgx.Tx)(nil) +) diff --git a/visionA-backend/internal/device/device.go b/visionA-backend/internal/device/device.go index 1bb044e..b04b9d0 100644 --- a/visionA-backend/internal/device/device.go +++ b/visionA-backend/internal/device/device.go @@ -174,6 +174,11 @@ func (r *InMemoryRepository) List(ctx context.Context, ownerUserID string) ([]*D } // Save 新增或更新 device(upsert by ID)。 +// +// remote_status / status 補預設值(offline / unknown),與 PostgresRepository.Save 一致: +// devices 表的這兩個欄位是 NOT NULL DEFAULT 'offline' / 'unknown',PG Save 對空值補預設後寫入。 +// in-memory 在此同樣補預設,避免「同一筆空狀態 device 經 PG 讀出 offline/unknown、 +// 經 in-memory 讀出空字串」的隱性落差(前端顯示 RemoteStatus,見 api/devices.go)。 func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error { if d == nil || d.ID == "" { return errors.New("device: Save requires non-nil device with ID") @@ -184,6 +189,13 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error { now := time.Now().UTC() // Copy 避免外部後續修改影響 store cp := *d + // 補預設值,對齊 PG NOT NULL DEFAULT 欄位語意(見上方說明)。 + if cp.RemoteStatus == "" { + cp.RemoteStatus = RemoteStatusOffline + } + if cp.Status == "" { + cp.Status = USBStatusUnknown + } if existing, ok := r.devices[d.ID]; ok && existing.DeletedAt == nil { cp.CreatedAt = existing.CreatedAt // 保留原始 CreatedAt } else if cp.CreatedAt.IsZero() { diff --git a/visionA-backend/internal/device/postgres_repository.go b/visionA-backend/internal/device/postgres_repository.go index 13868e6..ffda73d 100644 --- a/visionA-backend/internal/device/postgres_repository.go +++ b/visionA-backend/internal/device/postgres_repository.go @@ -78,7 +78,27 @@ func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error // GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。 // // 對齊 in-memory:同一個 serial 在不同 owner 下不互相干擾(owner 過濾)。 +// +// serial 空字串:Save 把空 serial 寫成 SQL NULL(見 SaveTx),故空 serial 查詢需用 +// serial_number IS NULL 比對(等號比較永不命中 NULL)。非空 serial 走參數化 = $2。 +// 此分支讓 PG 與 in-memory(d.SerialNumber == serial,空查空)語意一致。 func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) { + if serial == "" { + const qNull = `SELECT ` + deviceColumns + ` + FROM devices + WHERE owner_user_id = $1 AND serial_number IS NULL AND deleted_at IS NULL` + + row := r.pool.QueryRow(ctx, qNull, ownerUserID) + d, err := scanDevice(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("device: pg GetBySerial: %w", err) + } + return d, nil + } + const q = `SELECT ` + deviceColumns + ` FROM devices WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL` @@ -133,11 +153,23 @@ func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*D // 重註冊(已 soft-delete 的 serial)走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT; // partial unique 因舊列已 deleted 不阻擋(見 package 註解)。 func (r *PostgresRepository) Save(ctx context.Context, d *Device) error { + return r.SaveTx(ctx, r.pool, d) +} + +// SaveTx 與 Save 相同的 upsert 語意,但在傳入的 Querier(pool 或 tx)上執行。 +// +// 用於 pairing exchange 自建 device 時,與 session token 建立在同一交易內(pairing 收尾問題 #2): +// 「建 device + 建 session token」整筆原子——任一步失敗整筆 rollback,避免建了 device 卻沒建成 +// session token 的中間態。q 可為 *pgxpool.Pool(自動 commit)或 pgx.Tx(隨外層交易)。 +func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device) error { if d == nil || d.ID == "" { return errors.New("device: Save requires non-nil device with ID") } - // remote_status / status 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。 + // remote_status / status 補預設值(offline / unknown),避免空字串寫進「有預設」的 + // NOT NULL 欄位(devices.remote_status / status 為 NOT NULL DEFAULT)後語意混淆。 + // 此補預設行為與 InMemoryRepository.Save 一致(見 device.go),兩個實作對空狀態的 + // 處理刻意對齊,讓同一筆空狀態 device 不論走 PG 或 in-memory 都讀回 offline / unknown。 remoteStatus := d.RemoteStatus if remoteStatus == "" { remoteStatus = RemoteStatusOffline @@ -153,7 +185,25 @@ func (r *PostgresRepository) Save(ctx context.Context, d *Device) error { createdAt = d.CreatedAt.UTC() } // else: 留 nil → COALESCE($n, now()) - const q = ` + // serial_number:空字串寫成 SQL NULL(而非 '')。 + // + // 為什麼:devices.serial_number 是 nullable TEXT,partial unique index + // uq_devices_owner_serial_active (owner_user_id, serial_number) WHERE deleted_at IS NULL + // 對「非 NULL」值才強制唯一。SQL 規範下每個 NULL 互不相等,故多筆「無序號」的 + // device(同 owner)不互撞 unique;但空字串 '' 是個確定值,同 owner 多筆 '' 會撞。 + // + // 語意:實體裝置一定帶非空 serial(防重複註冊照舊生效);雲端 pairing exchange + // 自建的 device 沒有真實序號,正確表示是「無序號」(NULL) 而非 ''。如此同一 owner + // 多次 exchange 各建一筆 serial=NULL 的 distinct device、不撞 unique。 + // + // 未來 local-tool 上報真實 serial 時,直接把這個 NULL 更新成真值即可,無 reconciliation + // 成本(若先前捏一個假 serial 佔位,反而要額外處理覆蓋)。 + var serialNumber any + if d.SerialNumber != "" { + serialNumber = d.SerialNumber + } // else: 留 nil → 寫入 SQL NULL + + const sql = ` INSERT INTO devices ( id, owner_user_id, name, device_type, serial_number, remote_status, last_seen_at, last_connected_at, status, @@ -181,12 +231,12 @@ func (r *PostgresRepository) Save(ctx context.Context, d *Device) error { paired_at = EXCLUDED.paired_at, deleted_at = EXCLUDED.deleted_at` - _, err := r.pool.Exec(ctx, q, + _, err := q.Exec(ctx, sql, d.ID, // $1 d.OwnerUserID, // $2 d.Name, // $3 d.DeviceType, // $4 - d.SerialNumber, // $5 + serialNumber, // $5 remoteStatus, // $6 d.LastSeenAt, // $7 d.LastConnectedAt, // $8 diff --git a/visionA-backend/internal/device/postgres_repository_db_test.go b/visionA-backend/internal/device/postgres_repository_db_test.go index d938e92..d7464ff 100644 --- a/visionA-backend/internal/device/postgres_repository_db_test.go +++ b/visionA-backend/internal/device/postgres_repository_db_test.go @@ -360,6 +360,10 @@ func TestPG_ConcurrentRegisterSameSerial(t *testing.T) { } wg.Wait() + // 注意:errs 收集在各 goroutine(只寫各自 index、無共享寫衝突),但所有斷言 + // 都在 Wait 後的主 goroutine 進行。require.* 會走 t.FailNow → runtime.Goexit, + // 只能在測試主 goroutine 呼叫;若放進上面的 spawn goroutine 會是未定義行為。 + // 這是 Reviewer 標示的易退化點 —— 保持 require.ErrorAs 留在此迴圈(Wait 後、主 goroutine)。 var ok, conflict int for _, e := range errs { if e == nil { diff --git a/visionA-backend/internal/model/postgres_repository_db_test.go b/visionA-backend/internal/model/postgres_repository_db_test.go index 4223d23..d3c6bac 100644 --- a/visionA-backend/internal/model/postgres_repository_db_test.go +++ b/visionA-backend/internal/model/postgres_repository_db_test.go @@ -121,6 +121,31 @@ func TestPG_List_Filter(t *testing.T) { assert.Equal(t, id1, list[0].ID) } +// List 排序:宣稱 ORDER BY created_at DESC(最新在前)。建多筆不同 created_at、驗回傳順序。 +func TestPG_List_OrderByCreatedAtDesc(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + base := time.Now().UTC().Truncate(time.Microsecond) + // 刻意亂序插入(mid → oldest → newest),以證明排序來自查詢而非插入順序。 + idMid := uuid.NewString() + idOld := uuid.NewString() + idNew := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: idMid, OwnerUserID: owner, Name: "mid", StorageKey: "k", Source: SourceUploaded, CreatedAt: base.Add(-2 * time.Hour)})) + require.NoError(t, r.Save(ctx, &Model{ID: idOld, OwnerUserID: owner, Name: "old", StorageKey: "k", Source: SourceUploaded, CreatedAt: base.Add(-4 * time.Hour)})) + require.NoError(t, r.Save(ctx, &Model{ID: idNew, OwnerUserID: owner, Name: "new", StorageKey: "k", Source: SourceUploaded, CreatedAt: base})) + + list, err := r.List(ctx, ListFilter{OwnerUserID: owner}) + require.NoError(t, err) + require.Len(t, list, 3) + // 最新在前(DESC):new → mid → old。 + assert.Equal(t, []string{idNew, idMid, idOld}, []string{list[0].ID, list[1].ID, list[2].ID}, + "List 應依 created_at DESC(最新在前)排序") + // 額外以時間單調遞減交叉驗證,避免只靠 ID 對齊。 + assert.True(t, list[0].CreatedAt.After(list[1].CreatedAt), "list[0] 應較 list[1] 新") + assert.True(t, list[1].CreatedAt.After(list[2].CreatedAt), "list[1] 應較 list[2] 新") +} + func TestPG_Delete_SoftDelete(t *testing.T) { ctx := context.Background() r, _, owner := newPGRepo(t) @@ -217,7 +242,8 @@ func TestPG_Upsert_PreservesCreatedAt(t *testing.T) { assert.Equal(t, "v2", second.Name) assert.Equal(t, "k2", second.StorageKey) assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值") - assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進") + // 已 sleep 10ms,updated_at 必嚴格推進(嚴格 After,非 After-OR-Equal)。 + assert.True(t, second.UpdatedAt.After(first.UpdatedAt), "updated_at 應嚴格推進(sleep 10ms 後)") } // soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。 diff --git a/visionA-backend/internal/relay/local_handle_test.go b/visionA-backend/internal/relay/local_handle_test.go new file mode 100644 index 0000000..74a205d --- /dev/null +++ b/visionA-backend/internal/relay/local_handle_test.go @@ -0,0 +1,164 @@ +package relay + +import ( + "context" + "net" + "testing" + "time" + + "github.com/hashicorp/yamux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/session" +) + +// owner: testing agent(internal/relay local_handle 補測) +// +// 測試策略:LocalHandle 包住 *yamux.Session。我們不需要真實 WebSocket / 真實 tunnel — +// yamux 可直接跑在 net.Pipe() 之上(in-memory、純 Go、不需 docker), +// 這已足以驗證 LocalHandle 的「session 開 stream / 關閉 / IsClosed」邏輯。 +// 真正端對端的 yamux-over-WebSocket 由 relay 的 integration_test.go 涵蓋。 + +// newYamuxPair 用 net.Pipe 建一對 yamux session(server 給 LocalHandle,client 端配合)。 +// 回傳 server session(LocalHandle 用)+ client session(驅動 Accept,避免 Open 卡住)。 +func newYamuxPair(t *testing.T) (server, client *yamux.Session) { + t.Helper() + c1, c2 := net.Pipe() + + srv, err := yamux.Server(c1, yamux.DefaultConfig()) + require.NoError(t, err, "yamux.Server") + cli, err := yamux.Client(c2, yamux.DefaultConfig()) + require.NoError(t, err, "yamux.Client") + + t.Cleanup(func() { + _ = srv.Close() + _ = cli.Close() + _ = c1.Close() + _ = c2.Close() + }) + return srv, cli +} + +// --------------------------------------------------------------------------- +// Summary / RecordHeartbeat — 純邏輯(mutex-protected,不碰 yamux) +// --------------------------------------------------------------------------- + +func TestLocalHandle_Summary_ReflectsConstructorArgs(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "203.0.113.7:54321") + + sum := h.Summary() + require.NotNil(t, sum) + assert.Equal(t, "vAc_tok", sum.Token) + assert.Equal(t, "203.0.113.7:54321", sum.RemoteAddr) + assert.False(t, sum.ConnectedAt.IsZero(), "ConnectedAt 應已設定") + assert.False(t, sum.LastHeartbeat.IsZero(), "LastHeartbeat 應已設定") + // ConnectedAt 與 LastHeartbeat 初始相同 + assert.Equal(t, sum.ConnectedAt, sum.LastHeartbeat) +} + +func TestLocalHandle_Summary_ReturnsCopy(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + + // caller 改 snapshot 不應影響內部狀態(防中間態觀察 / 並發寫)。 + s1 := h.Summary() + s1.Token = "MUTATED" + s1.LastHeartbeat = time.Unix(0, 0) + + s2 := h.Summary() + assert.Equal(t, "vAc_tok", s2.Token, "Summary() 必須回副本,caller 修改不得污染內部") + assert.NotEqual(t, time.Unix(0, 0), s2.LastHeartbeat) +} + +func TestLocalHandle_RecordHeartbeat_UpdatesLastHeartbeat(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + + before := h.Summary().LastHeartbeat + newTime := before.Add(30 * time.Second) + h.RecordHeartbeat(newTime) + + after := h.Summary().LastHeartbeat + assert.True(t, after.Equal(newTime), "RecordHeartbeat 應更新 LastHeartbeat:want %v got %v", newTime, after) + // ConnectedAt 不應被動到 + assert.Equal(t, before, h.Summary().ConnectedAt) +} + +// --------------------------------------------------------------------------- +// OpenStream / IsClosed / Close — 跑在 net.Pipe 上的真 yamux(in-memory) +// --------------------------------------------------------------------------- + +func TestLocalHandle_OpenStream_Succeeds(t *testing.T) { + srv, cli := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + + // client 端需要 Accept 才能讓 server 端 Open 完成 handshake。 + acceptDone := make(chan net.Conn, 1) + go func() { + stream, err := cli.Accept() + if err == nil { + acceptDone <- stream + } else { + acceptDone <- nil + } + }() + + conn, err := h.OpenStream(context.Background()) + require.NoError(t, err, "session 健在時 OpenStream 應成功") + require.NotNil(t, conn) + t.Cleanup(func() { _ = conn.Close() }) + + select { + case s := <-acceptDone: + require.NotNil(t, s, "client 端應 Accept 到對應 stream") + _ = s.Close() + case <-time.After(2 * time.Second): + t.Fatal("client 端未在 2s 內 Accept 到 stream") + } +} + +func TestLocalHandle_IsClosed_FalseWhenLive(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + assert.False(t, h.IsClosed(), "活的 session IsClosed 應為 false") +} + +func TestLocalHandle_Close_MarksClosed(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + + require.NoError(t, h.Close()) + assert.True(t, h.IsClosed(), "Close 後 IsClosed 應為 true") +} + +// TestLocalHandle_OpenStream_AfterClose 驗證 close 後 OpenStream 回 ErrSessionClosed。 +func TestLocalHandle_OpenStream_AfterClose(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + + require.NoError(t, h.Close()) + + _, err := h.OpenStream(context.Background()) + assert.ErrorIs(t, err, session.ErrSessionClosed, "已關閉的 session OpenStream 應回 ErrSessionClosed") +} + +// TestLocalHandle_OpenStream_CtxCancelled 驗證 ctx 已取消時 OpenStream 早退回 ctx.Err()。 +func TestLocalHandle_OpenStream_CtxCancelled(t *testing.T) { + srv, _ := newYamuxPair(t) + h := NewLocalHandle(srv, "vAc_tok", "addr") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + _, err := h.OpenStream(ctx) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled, "ctx 取消時 OpenStream 應回 ctx.Err()") +} + +// TestLocalHandle_ImplementsHandle 編譯期 + 執行期確認介面一致。 +func TestLocalHandle_ImplementsHandle(t *testing.T) { + srv, _ := newYamuxPair(t) + var _ session.Handle = NewLocalHandle(srv, "t", "a") +} diff --git a/visionA-backend/internal/session/inmemory_store_test.go b/visionA-backend/internal/session/inmemory_store_test.go index 081c36f..cbe369c 100644 --- a/visionA-backend/internal/session/inmemory_store_test.go +++ b/visionA-backend/internal/session/inmemory_store_test.go @@ -189,6 +189,38 @@ func TestInMemoryStore_List(t *testing.T) { assert.True(t, tokens["b"]) } +// TestInMemoryStore_List_Empty 驗證空 store 回空 slice(不 nil-panic、不回 error)。 +func TestInMemoryStore_List_Empty(t *testing.T) { + s := NewInMemoryStore() + summaries, err := s.List(context.Background()) + require.NoError(t, err) + assert.Empty(t, summaries) +} + +// TestInMemoryStore_List_ReturnsCopies 驗證 List 回傳的是 Summary 副本: +// caller 改回傳值不得污染 handle 內部狀態(inmemory_store.go 註解承諾「複製 Summary」)。 +func TestInMemoryStore_List_ReturnsCopies(t *testing.T) { + ctx := context.Background() + s := NewInMemoryStore() + h := newFakeHandle("a", "u1", "d1") + require.NoError(t, s.Register(ctx, "a", h)) + + first, err := s.List(ctx) + require.NoError(t, err) + require.Len(t, first, 1) + + // 污染 caller 拿到的副本 + first[0].Token = "HACKED" + first[0].UserID = "HACKED" + + // 再查一次,內部 handle 的 Summary 不應被改到 + second, err := s.List(ctx) + require.NoError(t, err) + require.Len(t, second, 1) + assert.Equal(t, "a", second[0].Token, "List 必須回副本,caller 修改不得污染內部") + assert.Equal(t, "u1", second[0].UserID) +} + func TestInMemoryStore_CleanupExpired(t *testing.T) { ctx := context.Background() s := NewInMemoryStore() diff --git a/visionA-backend/internal/user/inmemory_store_test.go b/visionA-backend/internal/user/inmemory_store_test.go new file mode 100644 index 0000000..11f8be9 --- /dev/null +++ b/visionA-backend/internal/user/inmemory_store_test.go @@ -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 影響") +} diff --git a/visionA-backend/internal/user/postgres_store.go b/visionA-backend/internal/user/postgres_store.go new file mode 100644 index 0000000..d399c4d --- /dev/null +++ b/visionA-backend/internal/user/postgres_store.go @@ -0,0 +1,149 @@ +// Package user 的 Postgres 持久層實作(DB-on FK 收尾,問題 #1)。 +// +// PostgresStore 實作與 InMemoryStore 完全相同的 Store interface,讓 main.go 在 dbPool != nil +// 時無痛切換、OIDC callback 呼叫端一行都不需改。 +// +// 對齊: +// - migrations/0001_create_users_models.up.sql(users 表:id UUID PK、email NOT NULL + +// uq_users_email_lower、name、roles TEXT[] NOT NULL DEFAULT '{}'、created_at/updated_at/deleted_at) +// +// pattern 比照 internal/model/postgres_repository.go、internal/device/postgres_repository.go: +// - 參數化 SQL(無字串拼接使用者輸入) +// - upsert by id(ON CONFLICT (id) DO UPDATE)保留 created_at +// - scan helper、nullable 欄位以指標接、NULL → 空字串 +// +// email 唯一衝突語意(D1-B): +// +// upsert 以 id(OIDC sub)為衝突依據。若兩個不同 sub 共用同 email,會撞 uq_users_email_lower +// (lower(email) 唯一)→ INSERT/UPDATE 回 23505。雛形視為資料異常、原樣回錯(不靜默吞), +// 由 caller(OIDC callback)log 後回 500。實務上同一 IdP 下 sub↔email 一對一,極少發生。 +package user + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgresStore 是 User 的 PostgreSQL 持久層實作。 +type PostgresStore struct { + pool *pgxpool.Pool +} + +// NewPostgresStore 建立一個以 pgxpool 為後端的 Store。 +// +// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。 +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + return &PostgresStore{pool: pool} +} + +// 編譯時檢查:確保 PostgresStore 實作 Store。 +var _ Store = (*PostgresStore)(nil) + +// userColumns 是 SELECT 共用欄位清單(順序必須與 scanUser 對齊)。 +const userColumns = `id, email, name, roles, created_at, updated_at, deleted_at` + +// Upsert 新增或更新 user(upsert by id)。 +// +// 既存(ON CONFLICT (id))→ 更新 email/name/roles + updated_at,保留 created_at; +// 不存在 → 新建,created_at = now()。roles 為 nil 時寫空陣列(對齊 NOT NULL DEFAULT '{}')。 +func (s *PostgresStore) Upsert(ctx context.Context, in *User) error { + if in == nil || in.ID == "" { + return errors.New("user: Upsert requires non-nil user with ID") + } + if in.Email == "" { + return errors.New("user: Upsert requires non-empty email") + } + + // name nullable:空字串寫 NULL(對齊 in-memory zero value 與 scan NULL → 空字串)。 + var nameArg any + if in.Name != "" { + nameArg = in.Name + } + + // roles NOT NULL DEFAULT '{}':nil 時寫空陣列。 + roles := in.Roles + if roles == nil { + roles = []string{} + } + + const q = ` + INSERT INTO users (id, email, name, roles, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) + ON CONFLICT (id) DO UPDATE SET + email = EXCLUDED.email, + name = EXCLUDED.name, + roles = EXCLUDED.roles, + updated_at = now() + -- created_at 不在 UPDATE SET:保留原值(首次 INSERT 的 now())。 + -- deleted_at 不觸碰:upsert 不會「復活」已軟刪 user(雛形無刪 user 路徑,保守不動)。` + + if _, err := s.pool.Exec(ctx, q, in.ID, in.Email, nameArg, roles); err != nil { + return fmt.Errorf("user: pg Upsert: %w", err) + } + return nil +} + +// Get 取得單一 user;不存在或已軟刪除回 ErrNotFound。 +func (s *PostgresStore) Get(ctx context.Context, id string) (*User, error) { + const q = `SELECT ` + userColumns + ` + FROM users + WHERE id = $1 AND deleted_at IS NULL` + + row := s.pool.QueryRow(ctx, q, id) + u, err := scanUser(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("user: pg Get: %w", err) + } + return u, nil +} + +// ========================================================================== +// scan helper +// ========================================================================== + +// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面。 +type rowScanner interface { + Scan(dest ...any) error +} + +// scanUser 從一列掃出 *User。欄位順序必須與 userColumns 對齊。 +// +// name nullable → 以 *string 接、NULL 掃成空字串(對齊 in-memory zero value)。 +// roles TEXT[] → []string(pgx decode)。時間欄位正規化為 UTC。 +func scanUser(row rowScanner) (*User, error) { + var ( + u User + name *string + ) + err := row.Scan( + &u.ID, + &u.Email, + &name, + &u.Roles, + &u.CreatedAt, + &u.UpdatedAt, + &u.DeletedAt, + ) + if err != nil { + return nil, err + } + + if name != nil { + u.Name = *name + } + + u.CreatedAt = u.CreatedAt.UTC() + u.UpdatedAt = u.UpdatedAt.UTC() + if u.DeletedAt != nil { + d := u.DeletedAt.UTC() + u.DeletedAt = &d + } + return &u, nil +} diff --git a/visionA-backend/internal/user/postgres_store_db_test.go b/visionA-backend/internal/user/postgres_store_db_test.go new file mode 100644 index 0000000..9b0edf5 --- /dev/null +++ b/visionA-backend/internal/user/postgres_store_db_test.go @@ -0,0 +1,138 @@ +//go:build dbtest + +// PostgresStore(user)的真 DB 整合測試(DB-on FK 收尾,問題 #1)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 +// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/user/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/user/... +// +// 涵蓋: +// - Upsert 新建 + Get round-trip(id=sub UUID、email、name、roles) +// - Upsert 同 id 再寫:更新 email/name/roles、保留 created_at +// - email 大小寫不敏感唯一(uq_users_email_lower):兩個不同 sub 共用同 email(差大小寫)→ 撞 unique +// - Get 不存在 → ErrNotFound +// - roles nil → 寫空陣列(NOT NULL DEFAULT '{}')round-trip 回空 slice +package user + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db/testsupport" +) + +// newPGUserStore 啟動一次性測試 DB、truncate users,回傳 store。 +func newPGUserStore(t *testing.T) (*PostgresStore, *testsupport.TestDB) { + t.Helper() + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "users") + return NewPostgresStore(tdb.Pool), tdb +} + +func TestPGUser_UpsertAndGet(t *testing.T) { + ctx := context.Background() + s, _ := newPGUserStore(t) + + id := uuid.NewString() // 模擬 OIDC sub(UUID 格式,D1-B) + in := &User{ID: id, Email: "Alice@Example.com", Name: "Alice", Roles: []string{"admin", "user"}} + require.NoError(t, s.Upsert(ctx, in)) + + got, err := s.Get(ctx, id) + require.NoError(t, err) + assert.Equal(t, id, got.ID) + assert.Equal(t, "Alice@Example.com", got.Email) + assert.Equal(t, "Alice", got.Name) + assert.Equal(t, []string{"admin", "user"}, got.Roles) + assert.False(t, got.CreatedAt.IsZero()) + assert.False(t, got.UpdatedAt.IsZero()) +} + +func TestPGUser_Upsert_UpdatesAndPreservesCreatedAt(t *testing.T) { + ctx := context.Background() + s, _ := newPGUserStore(t) + + id := uuid.NewString() + require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "old@example.com", Name: "Old"})) + first, err := s.Get(ctx, id) + require.NoError(t, err) + origCreated := first.CreatedAt + + require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "new@example.com", Name: "New", Roles: []string{"r"}})) + second, err := s.Get(ctx, id) + require.NoError(t, err) + + assert.Equal(t, "new@example.com", second.Email, "email 應更新") + assert.Equal(t, "New", second.Name, "name 應更新") + assert.Equal(t, []string{"r"}, second.Roles, "roles 應更新") + assert.Equal(t, origCreated, second.CreatedAt, "created_at 應保留(ON CONFLICT 不動 created_at)") + assert.True(t, second.UpdatedAt.After(origCreated) || second.UpdatedAt.Equal(origCreated), + "updated_at 應 >= created_at") +} + +// TestPGUser_Upsert_EmailUniqueCaseInsensitive 驗證 uq_users_email_lower: +// 兩個不同 sub 共用同 email(差大小寫)→ 第二筆撞 lower(email) unique(23505)。 +// +// 這是 D1-B 下的已知資料異常邊界(雛形原樣回錯、不靜默吞)。 +func TestPGUser_Upsert_EmailUniqueCaseInsensitive(t *testing.T) { + ctx := context.Background() + s, _ := newPGUserStore(t) + + require.NoError(t, s.Upsert(ctx, &User{ID: uuid.NewString(), Email: "dup@example.com"})) + + err := s.Upsert(ctx, &User{ID: uuid.NewString(), Email: "DUP@example.com"}) + require.Error(t, err, "不同 sub 共用同 email(差大小寫)應撞 uq_users_email_lower") + var pgErr *pgconn.PgError + if assert.ErrorAs(t, err, &pgErr) { + assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation") + } +} + +func TestPGUser_Get_NotFound(t *testing.T) { + ctx := context.Background() + s, _ := newPGUserStore(t) + + _, err := s.Get(ctx, uuid.NewString()) + assert.ErrorIs(t, err, ErrNotFound) +} + +// TestPGUser_Upsert_NilRolesEmptyArray 驗證 roles nil → 寫空陣列 round-trip 回空/ nil。 +func TestPGUser_Upsert_NilRolesEmptyArray(t *testing.T) { + ctx := context.Background() + s, _ := newPGUserStore(t) + + id := uuid.NewString() + require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "noroles@example.com"})) + got, err := s.Get(ctx, id) + require.NoError(t, err) + assert.Empty(t, got.Roles, "roles 預設應為空(NOT NULL DEFAULT '{}')") +} + +// TestPGUser_Upsert_NameNullable 驗證 name 空字串 → 寫 NULL → 掃回空字串。 +func TestPGUser_Upsert_NameNullable(t *testing.T) { + ctx := context.Background() + s, tdb := newPGUserStore(t) + + id := uuid.NewString() + require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "noname@example.com"})) + + // 直接查 DB 確認 name 欄位是 NULL(非空字串)。 + var nameIsNull bool + require.NoError(t, tdb.Pool.QueryRow(ctx, + `SELECT name IS NULL FROM users WHERE id = $1`, id).Scan(&nameIsNull)) + assert.True(t, nameIsNull, "空 name 應寫成 NULL") + + got, err := s.Get(ctx, id) + require.NoError(t, err) + assert.Equal(t, "", got.Name, "NULL name 應掃回空字串") +} diff --git a/visionA-backend/internal/user/user.go b/visionA-backend/internal/user/user.go new file mode 100644 index 0000000..7231da1 --- /dev/null +++ b/visionA-backend/internal/user/user.go @@ -0,0 +1,135 @@ +// Package user 定義 User domain model 與 Store 介面。 +// +// 背景(DB-on FK 收尾,問題 #1): +// +// OIDC callback 驗 id_token 成功後只寫 cookie session,從不寫 users 表。DB-on(有 FK)下, +// 真人登入後任何「帶 owner_user_id FK」的寫入(上傳 model → models.owner_user_id、配對 → +// devices.owner_user_id、發 pairing token → pairing_tokens.user_id)都會 FK violation。 +// in-memory 模式因為不檢查 FK 而藏住此問題。 +// +// 修法(使用者拍板 D1-B):Member Center 的 OIDC sub 確認是 UUID/GUID 格式,故 sub 可直接 +// 當 users.id 主鍵 — 不需另開 oidc_sub 欄位、不需新 migration。callback 驗完 id_token 後 +// 呼叫 Store.Upsert 把 user 落 DB(DB-on 時),in-memory 模式也呼叫對齊行為。 +// +// 對齊 migrations/0001_create_users_models.up.sql 的 users 表 schema: +// - id UUID PK(D1-B 下即 OIDC sub) +// - email TEXT NOT NULL(uq_users_email_lower:lower(email) 唯一) +// - name TEXT(nullable) +// - roles TEXT[] NOT NULL DEFAULT '{}' +// - created_at / updated_at / deleted_at +// +// 雛形範圍刻意只含 OIDC callback provision 需要的最小欄位(id / email / name / roles)。 +// password_hash / org_id 等欄位由 DB DEFAULT 處理,本 store 不觸碰。 +package user + +import ( + "context" + "errors" + "sync" + "time" +) + +// ErrNotFound 表示指定 ID 的 User 不存在(或已軟刪除)。 +var ErrNotFound = errors.New("user: not found") + +// User 對應 migrations/0001 的 users 表(取 OIDC provision 所需欄位)。 +// +// D1-B:ID 即 OIDC sub(Member Center sub 為 UUID 格式,可直接當 PK)。 +type User struct { + ID string `json:"id"` // = OIDC sub(UUID) + Email string `json:"email"` // NOT NULL;upsert 必給 + Name string `json:"name,omitempty"` // nullable + Roles []string `json:"roles,omitempty"` // TEXT[] NOT NULL DEFAULT '{}' + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` +} + +// Store 是 User 持久層介面。 +// +// 兩個實作:InMemoryStore(local-dev fallback / 單元測試)+ PostgresStore(DB-on)。 +// main.go 依 dbPool 是否非 nil 擇一注入,OIDC callback 一行不需改地切換。 +type Store interface { + // Upsert 確保此 user 存在(insert 或更新 email/name/roles)。 + // + // 語意: + // - 以 ID(= OIDC sub)為主鍵衝突依據(ON CONFLICT (id))。 + // - 既存 → 更新 email / name / roles + updated_at,保留 created_at。 + // - 不存在 → 新建,created_at = now()。 + // - in 的 Email 不可為空(users.email NOT NULL);caller 須確保 OIDC email claim 存在。 + Upsert(ctx context.Context, in *User) error + + // Get 取得單一 user;不存在或已軟刪除回 ErrNotFound。 + Get(ctx context.Context, id string) (*User, error) +} + +// ========================================================================== +// InMemoryStore +// ========================================================================== + +// InMemoryStore 是 local-dev fallback / 單元測試用的記憶體實作。 +// +// 對齊 PostgresStore 的 Upsert 語意:既存保留 CreatedAt、更新 email/name/roles + UpdatedAt。 +type InMemoryStore struct { + mu sync.RWMutex + users map[string]*User // key = id(OIDC sub) +} + +// NewInMemoryStore 建立一個空的記憶體 Store。 +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{users: make(map[string]*User)} +} + +// Upsert 新增或更新 user(by ID),保留既有 CreatedAt。 +func (s *InMemoryStore) Upsert(ctx context.Context, in *User) error { + if in == nil || in.ID == "" { + return errors.New("user: Upsert requires non-nil user with ID") + } + if in.Email == "" { + return errors.New("user: Upsert requires non-empty email") + } + + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + cp := *in + cp.Roles = cloneRoles(in.Roles) + if existing, ok := s.users[in.ID]; ok && existing.DeletedAt == nil { + cp.CreatedAt = existing.CreatedAt // 保留原 CreatedAt + } else if cp.CreatedAt.IsZero() { + cp.CreatedAt = now + } + cp.UpdatedAt = now + cp.DeletedAt = nil + s.users[in.ID] = &cp + return nil +} + +// Get 取得單一 user。 +func (s *InMemoryStore) Get(ctx context.Context, id string) (*User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + u, ok := s.users[id] + if !ok || u.DeletedAt != nil { + return nil, ErrNotFound + } + cp := *u + cp.Roles = cloneRoles(u.Roles) + return &cp, nil +} + +// cloneRoles 複製 roles slice,避免外部後續修改影響 store(in-memory copy 語意)。 +func cloneRoles(in []string) []string { + if in == nil { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} + +// 編譯時檢查:確保 InMemoryStore 實作 Store。 +var _ Store = (*InMemoryStore)(nil) diff --git a/visionA-backend/internal/usersession/redis_integration_test.go b/visionA-backend/internal/usersession/redis_integration_test.go index 2527c21..4d87d0f 100644 --- a/visionA-backend/internal/usersession/redis_integration_test.go +++ b/visionA-backend/internal/usersession/redis_integration_test.go @@ -92,7 +92,16 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) { sess.Email = "real@example.com" sess.OIDCCodeVerifier = "cv-secret" sess.AccessToken = "at-secret" - sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)} + // 含非 ASCII(中文 + emoji + 特殊符號)的 Extra:驗證 JSON 在真 Redis(RESP binary + // 傳輸)下 UTF-8 round-trip 不被破壞。miniredis 不一定能暴露 binary 層問題,故在真 Redis 驗。 + const cjkVal = "返回首頁/設定 ⚙️ — 名稱「測試」" + const cjkKey = "中文鍵" + sess.Extra = map[string]any{ + "return_to": "/x", + "n": float64(7), + "zh": cjkVal, + cjkKey: "值🚀", + } if err := store.Update(ctx, sess); err != nil { t.Fatalf("Update: %v", err) } @@ -108,6 +117,13 @@ func TestRealRedis_RoundTripAndIsolation(t *testing.T) { if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) { t.Fatalf("Extra round-trip mismatch: %+v", got.Extra) } + // 非 ASCII round-trip:值與「中文鍵」皆須完整保留(含多位元組 UTF-8 與 emoji)。 + if got.Extra["zh"] != cjkVal { + t.Fatalf("non-ASCII Extra value round-trip mismatch: got %q want %q", got.Extra["zh"], cjkVal) + } + if got.Extra[cjkKey] != "值🚀" { + t.Fatalf("non-ASCII Extra key round-trip mismatch: got %q (key=%q)", got.Extra[cjkKey], cjkKey) + } // key 帶 prefix(用底層 client 直接驗)。 if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 {