package api import ( "context" "encoding/json" "mime" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/model" ) // newPresetFixture 建一個帶 preset 配置的 models route fixture。 // // presetDir / presetBaseURL 空時模擬「preset download 未配置」。 // 同時設 FileAccessIssuer=nil 確保 preset 分支完全獨立於 FAA。 func newPresetFixture(t *testing.T, presetDir, presetBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) { t.Helper() repo := model.NewInMemoryRepository() r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext(userID, "")) g := r.Group("/api") registerModelRoutes(g, Deps{ ModelRepo: repo, MaxUploadSizeMB: 10, PresetModelsDir: presetDir, PresetBaseURL: presetBaseURL, // FileAccessIssuer 刻意留 nil:preset download 不該依賴 FAA。 }) return r, repo } // ========================================================================== // list — preset 對所有人可見 // ========================================================================== func TestModelsList_IncludesPresetsForEveryone(t *testing.T) { r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) arr, ok := sb.Data.([]any) require.True(t, ok) require.Len(t, arr, 7, "無 user model 時應只回 7 個 preset") ids := map[string]bool{} for _, item := range arr { ids[item.(map[string]any)["id"].(string)] = true } for _, id := range []string{ "kl520-yolov5-detection", "kl520-fcos-detection", "kl520-ssd-face-detection", "kl520-tiny-yolov3", "kl720-yolov5-detection", "kl720-resnet18-classification", "kl720-fcos-detection", } { assert.True(t, ids[id], "preset %s 應在 list 中", id) } } // TestModelsList_PresetVisibleWhenRepoNil 驗證即使無 ModelRepo(最小骨架),preset 仍可見。 func TestModelsList_PresetVisibleWhenRepoNil(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") registerModelRoutes(g, Deps{MaxUploadSizeMB: 10}) // ModelRepo nil w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) arr, ok := sb.Data.([]any) require.True(t, ok) assert.Len(t, arr, 7) } // ========================================================================== // get — preset 查得到(無 ownership 限制) // ========================================================================== func TestModelsGet_PresetFound(t *testing.T) { r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "any-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl720-resnet18-classification", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "kl720-resnet18-classification", data["id"]) assert.Equal(t, "ImageNet Classification ResNet18 (KL720)", data["name"]) assert.Equal(t, "preset", data["source"]) assert.Equal(t, "KL720", data["target_chip"]) assert.Equal(t, "ready", data["status"]) } // ========================================================================== // download — 三分支:preset / converted / uploaded // ========================================================================== // TestModelsDownload_PresetReturnsVisionAURL:preset 回 visionA 自己的 URL(非 FAA、非 501)。 func TestModelsDownload_PresetReturnsVisionAURL(t *testing.T) { r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-yolov5-detection/download", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "http://localhost:3721/preset-models/kl520-yolov5-detection.nef", data["download_url"]) // preset 不簽 token:token 欄位應為空(omitempty 後可能不存在或為空字串)。 assert.Empty(t, data["token"]) } // TestModelsDownload_PresetWorksWithoutFAAConfig:preset download 完全不依賴 FAA 配置。 // fixture 的 FileAccessIssuer 為 nil(未配置 FAA),preset 仍應成功。 func TestModelsDownload_PresetWorksWithoutFAAConfig(t *testing.T) { r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl720-fcos-detection/download", nil)) assert.Equal(t, http.StatusOK, w.Code, "preset 不需 FAA 也能下載; body=%s", w.Body.String()) } // TestModelsDownload_PresetNotConfiguredReturns501:preset 目錄 / baseURL 未配置 → 501。 func TestModelsDownload_PresetNotConfiguredReturns501(t *testing.T) { r, _ := newPresetFixture(t, "", "", "demo-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-tiny-yolov3/download", nil)) assert.Equal(t, http.StatusNotImplemented, w.Code, "body=%s", w.Body.String()) } // TestModelsDownload_ConvertedStillUsesFAA:converted 類仍走 FAA(沒被 preset 分支破壞)。 func TestModelsDownload_ConvertedStillUsesFAA(t *testing.T) { exp := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) iss := &fakeIssuer{token: "fdt_abc", expiresAt: exp} r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") seedConvertedModel(t, repo, "m-conv", "demo-user", "models/demo-user/job.nef") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m-conv/download", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var sb SuccessBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) data := sb.Data.(map[string]any) assert.Equal(t, "https://faa.example.com:5081/files/models/demo-user/job.nef?access_token=fdt_abc", data["download_url"]) assert.Equal(t, 1, iss.calls, "converted 類仍應簽 FAA token") } // TestModelsDownload_UploadedStillReturns501:uploaded 類(無 FAAObjectKey、非 preset)仍 501。 func TestModelsDownload_UploadedStillReturns501(t *testing.T) { iss := &fakeIssuer{token: "fdt_x"} r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") now := time.Now().UTC() require.NoError(t, repo.Save(context.Background(), &model.Model{ ID: "m-upload", OwnerUserID: "demo-user", Name: "uploaded", StorageKey: "models/demo-user/m-upload.nef", Source: model.SourceUploaded, UploadedAt: &now, CreatedAt: now, UpdatedAt: now, })) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil)) assert.Equal(t, http.StatusNotImplemented, w.Code) assert.Equal(t, 0, iss.calls) } // ========================================================================== // delete — preset 不在 DB,delete 應回 404(不炸) // ========================================================================== func TestModelsDelete_PresetReturns404(t *testing.T) { r, _ := newPresetFixture(t, t.TempDir(), "http://localhost:3721", "demo-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/kl520-fcos-detection", nil)) // preset 不在 DB,ModelRepo.Get 回 ErrNotFound → 404(合理錯誤,不 panic)。 assert.Equal(t, http.StatusNotFound, w.Code, "body=%s", w.Body.String()) assert.Contains(t, w.Body.String(), ErrCodeNotFound) } // ========================================================================== // preset 靜態下載 handler(GET /preset-models/*) // ========================================================================== // newPresetServeFixture 建一個含實際 .nef 檔的 preset serve fixture。 func newPresetServeFixture(t *testing.T) (*gin.Engine, string) { t.Helper() dir := t.TempDir() content := []byte("fake-nef-bytes-kl520-yolov5") require.NoError(t, os.WriteFile(filepath.Join(dir, "kl520-yolov5-detection.nef"), content, 0o644)) r := gin.New() r.Use(RequestIDMiddleware()) // 註冊條件對稱(Minor-1):須同時帶 PresetModelsDir + PresetBaseURL 才註冊路由。 registerPresetModelRoutes(r, Deps{PresetModelsDir: dir, PresetBaseURL: "http://localhost:3721"}) return r, dir } func TestPresetServe_OK(t *testing.T) { r, _ := newPresetServeFixture(t) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/kl520-yolov5-detection.nef", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) assert.Equal(t, "fake-nef-bytes-kl520-yolov5", w.Body.String()) // Minor-2:Content-Disposition 經 mime.FormatMediaType 產生(安全檔名不會被 corrupt)。 // 安全字元檔名 mime 會輸出 unquoted;special char 才加引號 escape(防 header injection)。 cd := w.Header().Get("Content-Disposition") assert.True(t, strings.HasPrefix(cd, "attachment"), "應為 attachment disposition: %q", cd) assert.Contains(t, cd, "kl520-yolov5-detection.nef") // 可被標準 parser 正確還原 → 證明 header 結構合法(無破壞)。 mediaType, params, err := mime.ParseMediaType(cd) require.NoError(t, err, "Content-Disposition 應可被標準 parser 解析: %q", cd) assert.Equal(t, "attachment", mediaType) assert.Equal(t, "kl520-yolov5-detection.nef", params["filename"]) } func TestPresetServe_NotFound(t *testing.T) { r, _ := newPresetServeFixture(t) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/does-not-exist.nef", nil)) assert.Equal(t, http.StatusNotFound, w.Code) } // TestPresetServe_RejectsTraversal 驗證 path-traversal 被擋(不會讀到 root 外的檔)。 func TestPresetServe_RejectsTraversal(t *testing.T) { r, _ := newPresetServeFixture(t) // gin 會先 normalize URL path,故直接打 handler 用的 raw param 較難觸發; // 這裡驗證 handler 對含 ".." 的 filepath 一律 400(防禦深度)。 w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/preset-models/..%2f..%2fetc%2fpasswd", nil) r.ServeHTTP(w, req) assert.NotEqual(t, http.StatusOK, w.Code, "traversal 不應成功讀檔") } // TestPresetServe_NotRegisteredWhenDirEmpty 驗證 PresetModelsDir 空時不註冊路由。 func TestPresetServe_NotRegisteredWhenDirEmpty(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) registerPresetModelRoutes(r, Deps{PresetModelsDir: ""}) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/anything.nef", nil)) assert.Equal(t, http.StatusNotFound, w.Code, "路由未註冊 → gin 預設 404") } // TestPresetServe_NotRegisteredWhenBaseURLEmpty 驗證 Minor-1 對稱條件: // PresetModelsDir 有設但 PresetBaseURL 空 → route 也不註冊(與 download handler 501 對稱), // 避免「download 501 但 serve route 仍活著」的狀態不一致。 func TestPresetServe_NotRegisteredWhenBaseURLEmpty(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) registerPresetModelRoutes(r, Deps{PresetModelsDir: t.TempDir(), PresetBaseURL: ""}) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/preset-models/anything.nef", nil)) assert.Equal(t, http.StatusNotFound, w.Code, "PresetBaseURL 空 → route 不註冊 → 404") } // TestModelsDownload_PresetMissingBaseURLReturns501 驗證 download 端對稱條件: // PresetModelsDir 有設但 PresetBaseURL 空 → download preset 回 501(與 route 不註冊對稱)。 func TestModelsDownload_PresetMissingBaseURLReturns501(t *testing.T) { r, _ := newPresetFixture(t, t.TempDir(), "", "demo-user") w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/kl520-tiny-yolov3/download", nil)) assert.Equal(t, http.StatusNotImplemented, w.Code, "body=%s", w.Body.String()) }