// 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) }