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>
308 lines
11 KiB
Go
308 lines
11 KiB
Go
// 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)
|
||
}
|