package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/auth" "visiona-backend/internal/model" ) // canAccessModelForTest 以 userID(無 org)包一層 canAccessModel,方便單元測試。 func canAccessModelForTest(ctx context.Context, userID string, m *model.Model, shareLookup func(context.Context, string, string) (*model.ModelShare, error), ) model.AccessLevel { return canAccessModel(ctx, &auth.UserContext{UserID: userID}, m, shareLookup) } // ========================================================================== // fixture // ========================================================================== // newSharingFixture 建一個「以 userID 身份登入」的模型共享 route fixture。 func newSharingFixture(t *testing.T, 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, }) return r, repo } // seedReadyModel 塞一個 ready(已 finalize)的 model,指定 owner + visibility。 func seedReadyModel(t *testing.T, repo *model.InMemoryRepository, id, owner, visibility string) *model.Model { t.Helper() now := time.Now().UTC() m := &model.Model{ ID: id, OwnerUserID: owner, Name: "model-" + id, StorageKey: "models/" + owner + "/" + id + ".nef", FileSize: 1024, Source: model.SourceUploaded, Visibility: visibility, UploadedAt: &now, // ready } require.NoError(t, repo.Save(context.Background(), m)) return m } // decodeData 解 envelope 的 data 到 target。 func decodeData(t *testing.T, body []byte, target any) { t.Helper() var sb SuccessBody require.NoError(t, json.Unmarshal(body, &sb)) raw, err := json.Marshal(sb.Data) require.NoError(t, err) require.NoError(t, json.Unmarshal(raw, target)) } // ========================================================================== // GET /api/models/library — 可見性 matrix // ========================================================================== // TestLibrary_VisibilityMatrix 驗證共享庫只列可見 model:我的 + public + shared, // 不含別人的 private(TDD §4.1 predicate)。 func TestLibrary_VisibilityMatrix(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "mine-priv", "me", model.VisibilityPrivate) // 我的 private → 可見 seedReadyModel(t, repo, "other-priv", "other", model.VisibilityPrivate) // 別人 private → 不可見 seedReadyModel(t, repo, "other-pub", "other", model.VisibilityPublic) // 別人 public → 可見 shared := seedReadyModel(t, repo, "other-shared", "other", model.VisibilityPrivate) require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{ ModelID: shared.ID, GranteeUserID: "me", Role: "viewer", GrantedBy: "other", })) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/library?limit=100", nil) r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var resp LibraryResponse decodeData(t, w.Body.Bytes(), &resp) got := map[string]LibraryItemResponse{} for _, it := range resp.Items { got[it.ID] = it } assert.Contains(t, got, "mine-priv", "我的 private 應可見") assert.Contains(t, got, "other-pub", "別人 public 應可見") assert.Contains(t, got, "other-shared", "分享給我的應可見") assert.NotContains(t, got, "other-priv", "別人 private 不應可見") // my_access / is_me / shared_with_me 正確。 assert.Equal(t, model.AccessOwner, got["mine-priv"].MyAccess) assert.True(t, got["mine-priv"].Owner.IsMe) assert.Equal(t, model.AccessViewer, got["other-pub"].MyAccess) assert.False(t, got["other-pub"].Owner.IsMe) assert.True(t, got["other-shared"].SharedWithMe, "分享給我的應標 shared_with_me") } // TestLibrary_ExcludesNotReady 驗證未 finalize(pending)的 model 不進共享庫。 func TestLibrary_ExcludesNotReady(t *testing.T) { r, repo := newSharingFixture(t, "me") // pending model(UploadedAt=nil)。 require.NoError(t, repo.Save(context.Background(), &model.Model{ ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", FileSize: 1, Source: model.SourceUploaded, Visibility: model.VisibilityPublic, })) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/models/library", nil) r.ServeHTTP(w, req) var resp LibraryResponse decodeData(t, w.Body.Bytes(), &resp) assert.Empty(t, resp.Items, "pending model 不應進共享庫") } // TestLibrary_OwnedFilter 驗證 owned=true 只回我的、owned=false 只回別人分享/公開的。 func TestLibrary_OwnedFilter(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "mine", "me", model.VisibilityPrivate) seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic) // owned=true w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=true", nil)) var mineOnly LibraryResponse decodeData(t, w.Body.Bytes(), &mineOnly) require.Len(t, mineOnly.Items, 1) assert.Equal(t, "mine", mineOnly.Items[0].ID) // owned=false w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=false", nil)) var othersOnly LibraryResponse decodeData(t, w.Body.Bytes(), &othersOnly) require.Len(t, othersOnly.Items, 1) assert.Equal(t, "pub", othersOnly.Items[0].ID) } // TestLibrary_SearchQ 驗證 q 搜尋 name。 func TestLibrary_SearchQ(t *testing.T) { r, repo := newSharingFixture(t, "me") m1 := seedReadyModel(t, repo, "a", "me", model.VisibilityPrivate) m1.Name = "yolov5-detect" require.NoError(t, repo.Save(context.Background(), m1)) m2 := seedReadyModel(t, repo, "b", "me", model.VisibilityPrivate) m2.Name = "resnet-classify" require.NoError(t, repo.Save(context.Background(), m2)) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?q=yolo", nil)) var resp LibraryResponse decodeData(t, w.Body.Bytes(), &resp) require.Len(t, resp.Items, 1) assert.Equal(t, "a", resp.Items[0].ID) } // TestLibrary_CursorPagination 驗證 cursor 分頁不重複、不遺漏。 func TestLibrary_CursorPagination(t *testing.T) { r, repo := newSharingFixture(t, "me") for i := 0; i < 5; i++ { m := seedReadyModel(t, repo, string(rune('a'+i)), "me", model.VisibilityPrivate) // 讓 created_at 有序(sort=name 更穩定,用 name 分頁)。 _ = m } seen := map[string]bool{} cursor := "" pages := 0 for { url := "/api/models/library?limit=2&sort=name&order=asc" if cursor != "" { url += "&cursor=" + cursor } w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil)) require.Equal(t, http.StatusOK, w.Code) var resp LibraryResponse decodeData(t, w.Body.Bytes(), &resp) for _, it := range resp.Items { assert.False(t, seen[it.ID], "id %s 重複出現於分頁", it.ID) seen[it.ID] = true } pages++ require.Less(t, pages, 10, "分頁不應無限迴圈") if !resp.HasMore { break } cursor = resp.NextCursor require.NotEmpty(t, cursor, "has_more=true 時應有 next_cursor") } assert.Len(t, seen, 5, "所有 model 應被分頁完整走過一次") } // TestLibrary_InvalidParams 驗證非法 sort / limit / cursor 回 400。 func TestLibrary_InvalidParams(t *testing.T) { r, _ := newSharingFixture(t, "me") for _, url := range []string{ "/api/models/library?sort=bogus", "/api/models/library?limit=abc", "/api/models/library?order=sideways", "/api/models/library?cursor=!!!notbase64!!!", "/api/models/library?owned=maybe", } { w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil)) assert.Equal(t, http.StatusBadRequest, w.Code, "url=%s should be 400, body=%s", url, w.Body.String()) assert.Contains(t, w.Body.String(), ErrCodeValidationFailed) } } // ========================================================================== // GET /api/models/:id/profile // ========================================================================== // TestProfile_PublicVisibleToNonOwner 驗證 public model 非 owner 可看 profile, // 且 owner.name 有帶出(Minor-1:profile join owner name,對齊 api §2)。 func TestProfile_PublicVisibleToNonOwner(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic) repo.SetUserName("other", "Alice") // owner 顯示名 w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var p ProfileResponse decodeData(t, w.Body.Bytes(), &p) assert.Equal(t, model.AccessViewer, p.MyAccess) assert.True(t, p.CanDownload) assert.False(t, p.Owner.IsMe) assert.Equal(t, "other", p.Owner.ID) assert.Equal(t, "Alice", p.Owner.Name, "profile 應帶出 owner name(Minor-1)") } // TestProfile_PrivateHiddenReturns404 驗證別人 private model → profile 回 404(防 enumeration)。 func TestProfile_PrivateHiddenReturns404(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil)) assert.Equal(t, http.StatusNotFound, w.Code, "無權限應回 404,不是 403") assert.Contains(t, w.Body.String(), ErrCodeNotFound) } // TestProfile_NonExistentReturns404Same 驗證不存在的 id 與無權限的 id 回相同 404(enumeration 防護)。 func TestProfile_NonExistentReturns404Same(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate) wHidden := httptest.NewRecorder() r.ServeHTTP(wHidden, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil)) wMissing := httptest.NewRecorder() r.ServeHTTP(wMissing, httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/profile", nil)) assert.Equal(t, wMissing.Code, wHidden.Code, "無權限與不存在應回相同 status") // body 除了 request_id 外結構一致(都是 NOT_FOUND / model not found)。 assert.Contains(t, wHidden.Body.String(), "model not found") assert.Contains(t, wMissing.Body.String(), "model not found") } // TestProfile_NoLeakInternalKeys 驗證 profile 不洩漏 storage_key / faa_object_key / owner email(SEC-3)。 func TestProfile_NoLeakInternalKeys(t *testing.T) { r, repo := newSharingFixture(t, "me") m := seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic) m.FAAObjectKey = "models/other/secret-object-key.nef" m.FileChecksum = "sha256-secret" require.NoError(t, repo.Save(context.Background(), m)) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil)) body := w.Body.String() assert.NotContains(t, body, "secret-object-key", "不應洩漏 faa_object_key") assert.NotContains(t, body, "storage_key", "不應輸出 storage_key 欄") assert.NotContains(t, body, m.StorageKey, "不應洩漏 storage_key 值") assert.NotContains(t, body, "sha256-secret", "不應洩漏 file_checksum") assert.NotContains(t, body, "email", "不應輸出 owner email 欄") } // TestProfile_Preset 驗證 preset profile 任何登入 user 可看。 func TestProfile_Preset(t *testing.T) { r, _ := newSharingFixture(t, "me") presets := model.PresetModels() require.NotEmpty(t, presets) presetID := presets[0].ID w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/"+presetID+"/profile", nil)) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) var p ProfileResponse decodeData(t, w.Body.Bytes(), &p) assert.Equal(t, model.VisibilityPublic, p.Visibility) assert.True(t, p.CanDownload) } // ========================================================================== // PATCH /api/models/:id/visibility // ========================================================================== // TestSetVisibility_OwnerOK 驗證 owner 可改 visibility。 func TestSetVisibility_OwnerOK(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", strings.NewReader(`{"visibility":"public"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) m, err := repo.Get(context.Background(), "m") require.NoError(t, err) assert.Equal(t, model.VisibilityPublic, m.Visibility) } // TestSetVisibility_NonOwnerForbidden 驗證非 owner 改 visibility 回 403。 func TestSetVisibility_NonOwnerForbidden(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", strings.NewReader(`{"visibility":"public"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code) assert.Contains(t, w.Body.String(), ErrCodeForbidden) } // TestSetVisibility_Invalid 驗證非法 visibility 值回 400。 func TestSetVisibility_Invalid(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", strings.NewReader(`{"visibility":"world"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } // TestSetVisibility_TenantWithoutOrg 驗證 user 無 org 設 tenant 回 400。 func TestSetVisibility_TenantWithoutOrg(t *testing.T) { r, repo := newSharingFixture(t, "me") // injectStaticUserContext 不設 OrgID seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", strings.NewReader(`{"visibility":"tenant"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code, "無 org 設 tenant 應回 400") } // TestSetVisibility_NotReadyConflict 驗證未 finalize 的 model 設公開回 409。 func TestSetVisibility_NotReadyConflict(t *testing.T) { r, repo := newSharingFixture(t, "me") // pending model(UploadedAt=nil)。 require.NoError(t, repo.Save(context.Background(), &model.Model{ ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", FileSize: 1, Source: model.SourceUploaded, })) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPatch, "/api/models/pending/visibility", strings.NewReader(`{"visibility":"public"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusConflict, w.Code, "未 ready 設公開應回 409") } // ========================================================================== // shares CRUD(restricted 授權管理) // ========================================================================== // TestShares_PutListDelete 驗證 owner 加/列/移除授權完整流程。 func TestShares_PutListDelete(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) // PUT 加授權。 w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", strings.NewReader(`{"grantee_user_id":"bob","role":"viewer"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) // GET 列授權。 w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m/shares", nil)) require.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "bob") // DELETE 移除授權。 w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/bob", nil)) require.Equal(t, http.StatusNoContent, w.Code) // 再列應為空。 shares, err := repo.ListShares(context.Background(), "m") require.NoError(t, err) assert.Empty(t, shares) } // TestShares_NonOwnerForbidden 驗證非 owner 不能管理授權。 func TestShares_NonOwnerForbidden(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", strings.NewReader(`{"grantee_user_id":"bob"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code) } // TestShares_CannotShareToSelf 驗證不能分享給自己。 func TestShares_CannotShareToSelf(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", strings.NewReader(`{"grantee_user_id":"me"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } // TestShares_InvalidRole 驗證非法 role 回 400。 func TestShares_InvalidRole(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", strings.NewReader(`{"grantee_user_id":"bob","role":"admin"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } // TestShares_DeleteNonExistent 驗證移除不存在的授權回 404。 func TestShares_DeleteNonExistent(t *testing.T) { r, repo := newSharingFixture(t, "me") seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/ghost", nil)) assert.Equal(t, http.StatusNotFound, w.Code) } // ========================================================================== // canAccessModel 單元測試(single source of truth) // ========================================================================== // TestCanAccessModel_Levels 直接驗 canAccessModel 各級判斷。 func TestCanAccessModel_Levels(t *testing.T) { ctx := context.Background() noShare := func(context.Context, string, string) (*model.ModelShare, error) { return nil, model.ErrNotFound } mPrivate := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPrivate} mPublic := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPublic} // owner assert.Equal(t, model.AccessOwner, canAccessModelForTest(ctx, "owner", mPrivate, noShare)) // 無關 user + private → none assert.Equal(t, model.AccessNone, canAccessModelForTest(ctx, "stranger", mPrivate, noShare)) // public → viewer assert.Equal(t, model.AccessViewer, canAccessModelForTest(ctx, "stranger", mPublic, noShare)) // share viewer shareViewer := func(context.Context, string, string) (*model.ModelShare, error) { return &model.ModelShare{Role: "viewer"}, nil } assert.Equal(t, model.AccessViewer, canAccessModelForTest(ctx, "grantee", mPrivate, shareViewer)) // share editor shareEditor := func(context.Context, string, string) (*model.ModelShare, error) { return &model.ModelShare{Role: "editor"}, nil } assert.Equal(t, model.AccessEditor, canAccessModelForTest(ctx, "grantee", mPrivate, shareEditor)) // tenant 但 user 無 org(OIDC 現況)→ none(安全預設,stub) mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant} assert.Equal(t, model.AccessNone, canAccessModelForTest(ctx, "stranger", mTenant, noShare), "tenant + 無 org → none(tenant stub)") }