B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
515 lines
20 KiB
Go
515 lines
20 KiB
Go
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)")
|
||
}
|