package api import ( "bytes" "encoding/json" "errors" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" ) // fakeConsumer 是 tokenConsumer 的測試替身,記錄 Consume 被呼叫的參數, // 並可設定回傳的錯誤(模擬有效 / 已消費 / 過期 / deviceId 不符)。 type fakeConsumer struct { called bool gotToken string gotDeviceID string returnErr error } func (f *fakeConsumer) Consume(token, deviceID string) error { f.called = true f.gotToken = token f.gotDeviceID = deviceID return f.returnErr } // buildMultipart 建一個含 deviceId + file 欄位的 multipart body,回傳 body 與 content-type。 func buildMultipart(t *testing.T, deviceID string, fileContent []byte) (*bytes.Buffer, string) { t.Helper() var buf bytes.Buffer w := multipart.NewWriter(&buf) if err := w.WriteField("deviceId", deviceID); err != nil { t.Fatal(err) } fw, err := w.CreateFormFile("file", "test.mp4") if err != nil { t.Fatal(err) } if _, err := fw.Write(fileContent); err != nil { t.Fatal(err) } if err := w.Close(); err != nil { t.Fatal(err) } return &buf, w.FormDataContentType() } // newGuardRouter 建一台掛 LocalUploadGuard 的 router,handler 記錄是否被呼叫並回讀 file。 func newGuardRouter(store tokenConsumer, maxBytes int64, handlerCalled *bool) *gin.Engine { r := gin.New() r.POST("/api/local/media/upload/video", LocalUploadGuard(store, maxBytes), func(c *gin.Context) { *handlerCalled = true // 模擬既有 handler 讀 file(驗證 middleware 解析後 handler 仍可 FormFile) _, _, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"formfile_err": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }) return r } // decodeErrCode 從 response body 取出 error.code。 func decodeErrCode(t *testing.T, body []byte) string { t.Helper() var resp struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode body %q: %v", string(body), err) } return resp.Error.Code } // TestLocalUploadGuard_MissingToken:無 X-Visiona-Local-Token → 401 LOCAL_TOKEN_INVALID, // 且 store.Consume 不被呼叫、handler 不被呼叫。 func TestLocalUploadGuard_MissingToken(t *testing.T) { store := &fakeConsumer{} var handlerCalled bool r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) body, ct := buildMultipart(t, "dev-1", []byte("small")) req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) req.Header.Set("Content-Type", ct) // 刻意不帶 token w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", w.Code) } if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" { t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code) } if store.called { t.Error("無 token 不應呼叫 Consume") } if handlerCalled { t.Error("無 token 不應進入 handler") } } // TestLocalUploadGuard_ValidToken:有效 token → 放行、Consume 被呼叫且帶正確 token+deviceId、 // handler 被呼叫。 func TestLocalUploadGuard_ValidToken(t *testing.T) { store := &fakeConsumer{returnErr: nil} // Consume 成功 var handlerCalled bool r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) body, ct := buildMultipart(t, "dev-42", []byte("video-bytes")) req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) req.Header.Set("Content-Type", ct) req.Header.Set("X-Visiona-Local-Token", "tok-abc") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String()) } if !store.called { t.Fatal("有效 token 應呼叫 Consume") } if store.gotToken != "tok-abc" { t.Errorf("Consume token = %q, want tok-abc", store.gotToken) } if store.gotDeviceID != "dev-42" { t.Errorf("Consume deviceID = %q, want dev-42(取自表單)", store.gotDeviceID) } if !handlerCalled { t.Error("有效 token 應進入 handler") } } // TestLocalUploadGuard_ConsumedOrExpiredToken:Consume 回 ErrTokenInvalid(已用/過期/deviceId不符) // → 401 LOCAL_TOKEN_INVALID,handler 不被呼叫。 func TestLocalUploadGuard_ConsumedOrExpiredToken(t *testing.T) { store := &fakeConsumer{returnErr: ErrTokenInvalid} var handlerCalled bool r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) body, ct := buildMultipart(t, "dev-1", []byte("x")) req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) req.Header.Set("Content-Type", ct) req.Header.Set("X-Visiona-Local-Token", "stale-tok") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", w.Code) } if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" { t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code) } if handlerCalled { t.Error("無效 token 不應進入 handler") } } // TestLocalUploadGuard_TokenCheckedBeforeHandler:token 驗證發生在 handler(FormFile 讀檔)之前。 // 用「Consume 失敗時 handler 不被呼叫」+「Consume 成功時才進 handler」共同證明順序: // 若 handler 先跑,無效 token 情境下 handlerCalled 會是 true。 func TestLocalUploadGuard_TokenCheckedBeforeHandler(t *testing.T) { store := &fakeConsumer{returnErr: ErrTokenInvalid} var handlerCalled bool r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 1024)) req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) req.Header.Set("Content-Type", ct) req.Header.Set("X-Visiona-Local-Token", "bad") w := httptest.NewRecorder() r.ServeHTTP(w, req) if handlerCalled { t.Error("token 驗證失敗時 handler 不得被呼叫(證明 token 檢查在 handler 前)") } if !store.called { t.Error("Consume 應在進 handler 前被呼叫") } } // TestLocalUploadGuard_TooLarge:body 超過 size 上限 → 413 LOCAL_UPLOAD_TOO_LARGE, // handler 不被呼叫。用很小的 maxBytes 觸發。 func TestLocalUploadGuard_TooLarge(t *testing.T) { const tinyMax = 64 // 64 bytes,遠小於下方 body store := &fakeConsumer{returnErr: nil} var handlerCalled bool r := newGuardRouter(store, tinyMax, &handlerCalled) body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096)) req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) req.Header.Set("Content-Type", ct) req.Header.Set("X-Visiona-Local-Token", "tok") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status = %d, want 413 (body=%s)", w.Code, w.Body.String()) } if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_UPLOAD_TOO_LARGE" { t.Errorf("error code = %q, want LOCAL_UPLOAD_TOO_LARGE", code) } if handlerCalled { t.Error("超過 size 上限不應進入 handler") } } // TestLocalUploadGuard_TooLarge_BeforeTokenConsumed:超過上限時,即使帶了看似有效的 token, // 也不應 consume 掉那個 token(size 檢查在 consume 之前,避免大檔攻擊順手燒掉 token)。 func TestLocalUploadGuard_TooLarge_BeforeTokenConsumed(t *testing.T) { const tinyMax = 64 store := &fakeConsumer{returnErr: nil} var handlerCalled bool r := newGuardRouter(store, tinyMax, &handlerCalled) body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096)) req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) req.Header.Set("Content-Type", ct) req.Header.Set("X-Visiona-Local-Token", "tok") w := httptest.NewRecorder() r.ServeHTTP(w, req) if store.called { t.Error("超過 size 上限時不應呼叫 Consume(size 檢查在 consume 前)") } } // TestLocalUploadGuard_ErrorCodeMatchesSpec:確保錯誤碼字串與 api-spec §6.5 完全一致。 func TestLocalUploadGuard_ErrorCodeMatchesSpec(t *testing.T) { // 直接驗證 respondTokenInvalid 的輸出格式。 gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) respondTokenInvalid(c) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", w.Code) } if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_INVALID") { t.Errorf("body 應含 LOCAL_TOKEN_INVALID,got %s", w.Body.String()) } var resp struct { Success bool `json:"success"` } _ = json.Unmarshal(w.Body.Bytes(), &resp) if resp.Success { t.Error("錯誤回應 success 應為 false") } } // 確認 ErrTokenInvalid / ErrTokenLimit 是 sentinel(errors.Is 可比對),供 handler/middleware 對應錯誤碼。 func TestSentinelErrors(t *testing.T) { if !errors.Is(ErrTokenInvalid, ErrTokenInvalid) { t.Error("ErrTokenInvalid 應可自比對") } if errors.Is(ErrTokenInvalid, ErrTokenLimit) { t.Error("ErrTokenInvalid 與 ErrTokenLimit 不應相等") } }