- Device struct 加 4 欄(agent_id/agent_local_device_id/registered_at/
is_representative)+ deviceColumns 13→17 + scanDevice/SaveTx 讀寫新欄
- 新增 internal/agent package(domain + interface + in-memory + PG repo):
GetOrCreateAgentTx/GetByOwnerTx,advisory lock 序列化同 owner get-or-create
- exchange 重塑:建/復用 agent → representative device(綁 session_tokens、
serial=NULL)→ loop 建 N 顆真 USB device(R1 完整 N 顆非只第一顆)
- List filter is_representative=false + DeviceListItem 回傳 agent_id/registered_at
- 併入 WP-0/0005 follow-up Minor:Mi#2 lost-update 收斂(tx 內查詢+局部更新)
/ Mi#3 過時註解 / Mi#4 空 serial 回 ErrNotFound / Mi#5 serial 白名單
^0x[0-9A-Fa-f]{8}$ + 去重 / S-1 firmware forward-compat / S-2 device Name 衍生
守 ADR-018 A'(一 owner N agents、session_tokens FK 物理不動、不加 owner
unique 為多機器留路)。Reviewer 通過(0C/0M)。5 套件 dbtest 130 全綠
(db 19/device 37/agent 13/api 172/cmd 60)、build/vet/test 綠。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
4.6 KiB
Go
103 lines
4.6 KiB
Go
// 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 + A' 重塑(WP-B B3/B4):pairing exchange
|
||
// 後建 agent 的 representative device 供 session token 綁定。
|
||
//
|
||
// A' 語意變更:exchange 無 USB 上報時只建 representative device(is_representative=true),
|
||
// 而 List(B4)會 filter 掉 representative(只列真 USB)——故 exchange 前後 List 都為空。
|
||
// 「device 有被建」改由「representative device 存在且綁到 session」驗證。
|
||
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 名下無(真 USB)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)→ 建 agent + representative 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 後:List(真 USB)仍為空——A' 下無 USB 上報只建 representative(B4 filter)。
|
||
after, err := f.deviceRepo.List(context.Background(), sub)
|
||
require.NoError(t, err)
|
||
require.Empty(t, after, "無 USB 上報 → List(真 USB)仍為空(representative 被 filter)")
|
||
|
||
// 4) session token 綁到一顆存在的 representative device(is_representative=true、owner 對齊)。
|
||
tok, err := f.sessionTokenStore.Get(context.Background(), sessionTok)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, tok.DeviceID, "session token 應綁一個 device")
|
||
rep, err := f.deviceRepo.Get(context.Background(), tok.DeviceID)
|
||
require.NoError(t, err, "session 綁的 representative device 應存在")
|
||
assert.True(t, rep.IsRepresentative, "exchange 建的應為 representative device")
|
||
assert.Equal(t, sub, rep.OwnerUserID, "device owner 應為登入 user")
|
||
assert.NotNil(t, rep.PairedAt, "representative device 應設 paired_at")
|
||
}
|