jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:35 +08:00

308 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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