Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
646 lines
21 KiB
Go
646 lines
21 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// gormErrRecordNotFound is the sentinel the fake repo returns when an
|
|
// atomic helper is asked to touch a row that no longer exists, matching
|
|
// the real repo's behaviour. Kept as a package-level alias so the
|
|
// tests can reference it without re-importing gorm at every call site.
|
|
var gormErrRecordNotFound = gorm.ErrRecordNotFound
|
|
|
|
// fakeTenantMemberRepo is an in-memory implementation of
|
|
// interfaces.TenantMemberRepository for unit tests. It is intentionally
|
|
// not safe for concurrent use; tests should drive it sequentially.
|
|
type fakeTenantMemberRepo struct {
|
|
rows []*types.TenantMember
|
|
// nextID is incremented on Create to populate the surrogate PK.
|
|
nextID uint64
|
|
// failGet, failHasAny etc. let tests inject transient errors on the
|
|
// matching method to exercise error paths.
|
|
failGet error
|
|
failHasAny error
|
|
failCountOwners error
|
|
failUpdateRole error
|
|
failSoftDelete error
|
|
failCreate error
|
|
}
|
|
|
|
func newFakeRepo() *fakeTenantMemberRepo { return &fakeTenantMemberRepo{} }
|
|
|
|
func (r *fakeTenantMemberRepo) Create(ctx context.Context, m *types.TenantMember) error {
|
|
if r.failCreate != nil {
|
|
return r.failCreate
|
|
}
|
|
r.nextID++
|
|
m.ID = r.nextID
|
|
// Mirror the partial unique index on (user_id, tenant_id) for
|
|
// active rows so tests can exercise duplicate-insert behaviour.
|
|
for _, e := range r.rows {
|
|
if e.UserID == m.UserID && e.TenantID == m.TenantID && e.DeletedAt.Valid == false {
|
|
return errors.New("duplicate active membership")
|
|
}
|
|
}
|
|
cp := *m
|
|
r.rows = append(r.rows, &cp)
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) Get(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error) {
|
|
if r.failGet != nil {
|
|
return nil, r.failGet
|
|
}
|
|
for _, e := range r.rows {
|
|
if e.UserID == userID && e.TenantID == tenantID && !e.DeletedAt.Valid {
|
|
cp := *e
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error) {
|
|
var out []*types.TenantMember
|
|
for _, e := range r.rows {
|
|
if e.UserID == userID && !e.DeletedAt.Valid {
|
|
cp := *e
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error) {
|
|
var out []*types.TenantMember
|
|
for _, e := range r.rows {
|
|
if e.TenantID == tenantID && !e.DeletedAt.Valid {
|
|
cp := *e
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) filterTenantRows(tenantID uint64, search string) []*types.TenantMember {
|
|
search = strings.TrimSpace(strings.ToLower(search))
|
|
var out []*types.TenantMember
|
|
for _, e := range r.rows {
|
|
if e.TenantID != tenantID || e.DeletedAt.Valid {
|
|
continue
|
|
}
|
|
if search != "" {
|
|
if !strings.Contains(strings.ToLower(e.UserID), search) {
|
|
continue
|
|
}
|
|
}
|
|
cp := *e
|
|
out = append(out, &cp)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].JoinedAt.Equal(out[j].JoinedAt) {
|
|
return out[i].ID < out[j].ID
|
|
}
|
|
return out[i].JoinedAt.Before(out[j].JoinedAt)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) CountFilteredByTenant(
|
|
ctx context.Context, tenantID uint64, search string,
|
|
) (int64, error) {
|
|
return int64(len(r.filterTenantRows(tenantID, search))), nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) ListPagedByTenant(
|
|
ctx context.Context, tenantID uint64, search string, offset, limit int,
|
|
) ([]*types.TenantMember, error) {
|
|
all := r.filterTenantRows(tenantID, search)
|
|
if offset >= len(all) {
|
|
return []*types.TenantMember{}, nil
|
|
}
|
|
end := offset + limit
|
|
if end > len(all) {
|
|
end = len(all)
|
|
}
|
|
return append([]*types.TenantMember(nil), all[offset:end]...), nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) UpdateRole(ctx context.Context, userID string, tenantID uint64, role types.TenantRole) error {
|
|
if r.failUpdateRole != nil {
|
|
return r.failUpdateRole
|
|
}
|
|
for _, e := range r.rows {
|
|
if e.UserID == userID && e.TenantID == tenantID && !e.DeletedAt.Valid {
|
|
e.Role = role
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("not found")
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) SoftDelete(ctx context.Context, userID string, tenantID uint64) error {
|
|
if r.failSoftDelete != nil {
|
|
return r.failSoftDelete
|
|
}
|
|
for _, e := range r.rows {
|
|
if e.UserID == userID && e.TenantID == tenantID && !e.DeletedAt.Valid {
|
|
e.DeletedAt.Valid = true
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("not found")
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) CountActiveOwners(ctx context.Context, tenantID uint64) (int64, error) {
|
|
if r.failCountOwners != nil {
|
|
return 0, r.failCountOwners
|
|
}
|
|
var n int64
|
|
for _, e := range r.rows {
|
|
if e.TenantID == tenantID && !e.DeletedAt.Valid &&
|
|
e.Role == types.TenantRoleOwner && e.Status == types.TenantMemberStatusActive {
|
|
n++
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error) {
|
|
if r.failHasAny != nil {
|
|
return false, r.failHasAny
|
|
}
|
|
for _, e := range r.rows {
|
|
if e.TenantID == tenantID && !e.DeletedAt.Valid && e.Status == types.TenantMemberStatusActive {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// DemoteOwnerAtomically and RemoveOwnerAtomically mimic the production
|
|
// repo's transactional invariants: count other active Owners, fail
|
|
// closed when there are none, otherwise apply the role change /
|
|
// soft-delete in-memory. The fake doesn't simulate row-level locks
|
|
// because every Go test is single-goroutine here; the production
|
|
// transaction is exercised via the repo tests against a real DB.
|
|
func (r *fakeTenantMemberRepo) DemoteOwnerAtomically(
|
|
ctx context.Context, userID string, tenantID uint64, newRole types.TenantRole,
|
|
) error {
|
|
others := int64(0)
|
|
var target *types.TenantMember
|
|
for _, e := range r.rows {
|
|
if e.TenantID != tenantID || e.DeletedAt.Valid || e.Status != types.TenantMemberStatusActive {
|
|
continue
|
|
}
|
|
if e.Role == types.TenantRoleOwner && e.UserID != userID {
|
|
others++
|
|
}
|
|
if e.UserID == userID {
|
|
target = e
|
|
}
|
|
}
|
|
if others == 0 {
|
|
return apprepo.ErrLastOwner
|
|
}
|
|
if target == nil {
|
|
return gormErrRecordNotFound
|
|
}
|
|
target.Role = newRole
|
|
target.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeTenantMemberRepo) RemoveOwnerAtomically(
|
|
ctx context.Context, userID string, tenantID uint64,
|
|
) error {
|
|
others := int64(0)
|
|
var target *types.TenantMember
|
|
for _, e := range r.rows {
|
|
if e.TenantID != tenantID || e.DeletedAt.Valid || e.Status != types.TenantMemberStatusActive {
|
|
continue
|
|
}
|
|
if e.Role == types.TenantRoleOwner && e.UserID != userID {
|
|
others++
|
|
}
|
|
if e.UserID == userID {
|
|
target = e
|
|
}
|
|
}
|
|
if others == 0 {
|
|
return apprepo.ErrLastOwner
|
|
}
|
|
if target == nil {
|
|
return gormErrRecordNotFound
|
|
}
|
|
target.DeletedAt.Time = time.Now()
|
|
target.DeletedAt.Valid = true
|
|
return nil
|
|
}
|
|
|
|
// Compile-time guard so the test stays in sync with the interface.
|
|
var _ interfaces.TenantMemberRepository = (*fakeTenantMemberRepo)(nil)
|
|
|
|
func newServiceWithRepo() (interfaces.TenantMemberService, *fakeTenantMemberRepo) {
|
|
r := newFakeRepo()
|
|
// Audit / user / token dependencies are intentionally nil — these
|
|
// tests pre-date PR 6 and exercise membership invariants only. The
|
|
// service's audit and RemoveMember-cleanup hooks are nil-safe, so
|
|
// passing nil keeps existing coverage intact without forcing stubs.
|
|
return NewTenantMemberService(r, nil, nil, nil), r
|
|
}
|
|
|
|
// cleanupUserRepo is a minimal UserRepository used to assert that
|
|
// RemoveMember clears dangling home-tenant pointers (#2586).
|
|
type cleanupUserRepo struct {
|
|
users map[string]*types.User
|
|
}
|
|
|
|
func (r *cleanupUserRepo) CreateUser(context.Context, *types.User) error { return nil }
|
|
func (r *cleanupUserRepo) GetUserByID(_ context.Context, id string) (*types.User, error) {
|
|
u, ok := r.users[id]
|
|
if !ok {
|
|
return nil, errors.New("user not found")
|
|
}
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
func (r *cleanupUserRepo) GetUsersByIDs(context.Context, []string) (map[string]*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupUserRepo) GetUserByEmail(context.Context, string) (*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupUserRepo) GetUserByUsername(context.Context, string) (*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupUserRepo) GetUserByTenantID(context.Context, uint64) (*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupUserRepo) UpdateUser(_ context.Context, user *types.User) error {
|
|
cp := *user
|
|
r.users[user.ID] = &cp
|
|
return nil
|
|
}
|
|
func (r *cleanupUserRepo) DeleteUser(context.Context, string) error { return nil }
|
|
func (r *cleanupUserRepo) ListUsers(context.Context, int, int) ([]*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupUserRepo) ListSystemAdmins(context.Context, int, int) ([]*types.User, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
func (r *cleanupUserRepo) RevokeSystemAdmin(context.Context, string, string) (*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupUserRepo) SearchUsers(context.Context, string, int) ([]*types.User, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type cleanupTokenRepo struct {
|
|
revoked []string
|
|
}
|
|
|
|
func (r *cleanupTokenRepo) CreateToken(context.Context, *types.AuthToken) error { return nil }
|
|
func (r *cleanupTokenRepo) GetTokenByValue(context.Context, string) (*types.AuthToken, error) {
|
|
return nil, errors.New("not found")
|
|
}
|
|
func (r *cleanupTokenRepo) GetTokensByUserID(context.Context, string) ([]*types.AuthToken, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *cleanupTokenRepo) UpdateToken(context.Context, *types.AuthToken) error { return nil }
|
|
func (r *cleanupTokenRepo) DeleteToken(context.Context, string) error { return nil }
|
|
func (r *cleanupTokenRepo) DeleteExpiredTokens(context.Context) error { return nil }
|
|
func (r *cleanupTokenRepo) RevokeTokensByUserID(_ context.Context, userID string) error {
|
|
r.revoked = append(r.revoked, userID)
|
|
return nil
|
|
}
|
|
|
|
func TestTenantMemberService_RemoveMember_ClearsStaleHomeAndRevokesTokens(t *testing.T) {
|
|
memberRepo := newFakeRepo()
|
|
prefTenant := uint64(7)
|
|
userRepo := &cleanupUserRepo{users: map[string]*types.User{
|
|
"contrib": {
|
|
ID: "contrib",
|
|
TenantID: 7,
|
|
Preferences: types.UserPreferences{
|
|
LastActiveTenantID: &prefTenant,
|
|
},
|
|
},
|
|
}}
|
|
tokenRepo := &cleanupTokenRepo{}
|
|
svc := NewTenantMemberService(memberRepo, nil, userRepo, tokenRepo)
|
|
ctx := context.Background()
|
|
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 7); err != nil {
|
|
t.Fatalf("seed owner: %v", err)
|
|
}
|
|
if _, err := svc.AddMember(ctx, "contrib", 7, types.TenantRoleContributor, nil); err != nil {
|
|
t.Fatalf("seed contributor: %v", err)
|
|
}
|
|
if err := svc.RemoveMember(ctx, "contrib", 7); err != nil {
|
|
t.Fatalf("RemoveMember: %v", err)
|
|
}
|
|
|
|
got := userRepo.users["contrib"]
|
|
if got == nil {
|
|
t.Fatal("user missing after cleanup")
|
|
}
|
|
if got.TenantID != 0 {
|
|
t.Fatalf("TenantID = %d, want 0 after home membership removal", got.TenantID)
|
|
}
|
|
if got.Preferences.LastActiveTenantID != nil {
|
|
t.Fatalf("LastActiveTenantID = %v, want nil", got.Preferences.LastActiveTenantID)
|
|
}
|
|
if len(tokenRepo.revoked) != 1 || tokenRepo.revoked[0] != "contrib" {
|
|
t.Fatalf("revoked users = %v, want [contrib]", tokenRepo.revoked)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_RemoveMember_RevokesTokensEvenWhenHomeUnchanged(t *testing.T) {
|
|
// User's home is tenant 1; they are removed from tenant 7. Home
|
|
// pointer stays, but sessions must still be revoked so a JWT scoped
|
|
// to tenant 7 cannot keep serving a 403-only UI.
|
|
memberRepo := newFakeRepo()
|
|
userRepo := &cleanupUserRepo{users: map[string]*types.User{
|
|
"contrib": {ID: "contrib", TenantID: 1},
|
|
}}
|
|
tokenRepo := &cleanupTokenRepo{}
|
|
svc := NewTenantMemberService(memberRepo, nil, userRepo, tokenRepo)
|
|
ctx := context.Background()
|
|
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 7); err != nil {
|
|
t.Fatalf("seed owner: %v", err)
|
|
}
|
|
if _, err := svc.AddMember(ctx, "contrib", 7, types.TenantRoleContributor, nil); err != nil {
|
|
t.Fatalf("seed contributor: %v", err)
|
|
}
|
|
if err := svc.RemoveMember(ctx, "contrib", 7); err != nil {
|
|
t.Fatalf("RemoveMember: %v", err)
|
|
}
|
|
|
|
got := userRepo.users["contrib"]
|
|
if got.TenantID != 1 {
|
|
t.Fatalf("TenantID = %d, want home 1 left untouched", got.TenantID)
|
|
}
|
|
if len(tokenRepo.revoked) != 1 || tokenRepo.revoked[0] != "contrib" {
|
|
t.Fatalf("revoked users = %v, want [contrib]", tokenRepo.revoked)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_AddMember_RejectsInvalidRole(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
_, err := svc.AddMember(context.Background(), "u1", 1, types.TenantRole("nonsense"), nil)
|
|
if !errors.Is(err, ErrInvalidTenantRole) {
|
|
t.Fatalf("want ErrInvalidTenantRole, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_AddMember_APIKeyCannotAssignOwner(t *testing.T) {
|
|
svc, repo := newServiceWithRepo()
|
|
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
|
|
KeyID: 1,
|
|
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageMembers)},
|
|
})
|
|
|
|
_, err := svc.AddMember(ctx, "u1", 1, types.TenantRoleOwner, nil)
|
|
if !errors.Is(err, ErrAPIKeyCannotAssignOwner) {
|
|
t.Fatalf("want ErrAPIKeyCannotAssignOwner, got %v", err)
|
|
}
|
|
if len(repo.rows) != 0 {
|
|
t.Fatalf("API key owner assignment must not create a membership, got %d rows", len(repo.rows))
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_AddMember_RejectsDuplicate(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.AddMember(ctx, "u1", 1, types.TenantRoleContributor, nil); err != nil {
|
|
t.Fatalf("first AddMember: %v", err)
|
|
}
|
|
_, err := svc.AddMember(ctx, "u1", 1, types.TenantRoleContributor, nil)
|
|
if !errors.Is(err, ErrMembershipAlreadyExists) {
|
|
t.Fatalf("want ErrMembershipAlreadyExists, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_AddMember_MapsDuplicateKeyRace(t *testing.T) {
|
|
// Simulate the TOCTOU race: Get() saw no row, then a concurrent
|
|
// AddMember inserted before us, so our Create hits the partial
|
|
// unique index. The DB returns a duplicate-key error, which the
|
|
// service must translate into ErrMembershipAlreadyExists so the
|
|
// handler returns 409 rather than a generic 500.
|
|
svc, repo := newServiceWithRepo()
|
|
repo.failCreate = errors.New(
|
|
"ERROR: duplicate key value violates unique constraint \"idx_tenant_members_user_tenant_unique\"")
|
|
_, err := svc.AddMember(context.Background(), "u_race", 1, types.TenantRoleContributor, nil)
|
|
if !errors.Is(err, ErrMembershipAlreadyExists) {
|
|
t.Fatalf("want ErrMembershipAlreadyExists on duplicate-key race, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_EnsureOwner_Idempotent(t *testing.T) {
|
|
svc, repo := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
first, err := svc.EnsureOwner(ctx, "u1", 1)
|
|
if err != nil {
|
|
t.Fatalf("first EnsureOwner: %v", err)
|
|
}
|
|
second, err := svc.EnsureOwner(ctx, "u1", 1)
|
|
if err != nil {
|
|
t.Fatalf("second EnsureOwner: %v", err)
|
|
}
|
|
if first.ID != second.ID {
|
|
t.Fatalf("EnsureOwner not idempotent: %d vs %d", first.ID, second.ID)
|
|
}
|
|
if len(repo.rows) != 1 {
|
|
t.Fatalf("want exactly 1 row after idempotent EnsureOwner, got %d", len(repo.rows))
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_UpdateRole_BlocksDemotingLastOwner(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 1); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
err := svc.UpdateRole(ctx, "owner", 1, types.TenantRoleAdmin)
|
|
if !errors.Is(err, ErrLastOwner) {
|
|
t.Fatalf("want ErrLastOwner when demoting last owner, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_UpdateRole_APIKeyCannotPromoteOwner(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
humanCtx := context.Background()
|
|
if _, err := svc.AddMember(humanCtx, "u1", 1, types.TenantRoleAdmin, nil); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
apiKeyCtx := types.WithTenantAPIKeyScope(humanCtx, types.TenantAPIKeyScope{
|
|
KeyID: 1,
|
|
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageMembers)},
|
|
})
|
|
|
|
err := svc.UpdateRole(apiKeyCtx, "u1", 1, types.TenantRoleOwner)
|
|
if !errors.Is(err, ErrAPIKeyCannotAssignOwner) {
|
|
t.Fatalf("want ErrAPIKeyCannotAssignOwner, got %v", err)
|
|
}
|
|
member, getErr := svc.GetMembership(humanCtx, "u1", 1)
|
|
if getErr != nil {
|
|
t.Fatalf("get membership: %v", getErr)
|
|
}
|
|
if member == nil || member.Role != types.TenantRoleAdmin {
|
|
t.Fatalf("API key promotion must leave role unchanged, got %+v", member)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_UpdateRole_AllowsDemotionWhenOtherOwnerExists(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.EnsureOwner(ctx, "owner1", 1); err != nil {
|
|
t.Fatalf("seed1: %v", err)
|
|
}
|
|
if _, err := svc.AddMember(ctx, "owner2", 1, types.TenantRoleOwner, nil); err != nil {
|
|
t.Fatalf("seed2: %v", err)
|
|
}
|
|
if err := svc.UpdateRole(ctx, "owner1", 1, types.TenantRoleAdmin); err != nil {
|
|
t.Fatalf("UpdateRole: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_UpdateRole_NoopOnSameRole(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 1); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
// 把"还是 Owner"作为 no-op 处理,必须不触发 ErrLastOwner(同一角色不算降级)。
|
|
if err := svc.UpdateRole(ctx, "owner", 1, types.TenantRoleOwner); err != nil {
|
|
t.Fatalf("UpdateRole same role should be a no-op, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_UpdateRole_RejectsInvalidRole(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 1); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := svc.UpdateRole(ctx, "owner", 1, types.TenantRole("nope")); !errors.Is(err, ErrInvalidTenantRole) {
|
|
t.Fatalf("want ErrInvalidTenantRole, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_UpdateRole_ReturnsNotFound(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
if err := svc.UpdateRole(context.Background(), "ghost", 1, types.TenantRoleAdmin); !errors.Is(err, ErrMembershipNotFound) {
|
|
t.Fatalf("want ErrMembershipNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_RemoveMember_BlocksLastOwner(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 1); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := svc.RemoveMember(ctx, "owner", 1); !errors.Is(err, ErrLastOwner) {
|
|
t.Fatalf("want ErrLastOwner, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_RemoveMember_AllowsContributorRemoval(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
if _, err := svc.EnsureOwner(ctx, "owner", 1); err != nil {
|
|
t.Fatalf("seed owner: %v", err)
|
|
}
|
|
if _, err := svc.AddMember(ctx, "contrib", 1, types.TenantRoleContributor, nil); err != nil {
|
|
t.Fatalf("seed contributor: %v", err)
|
|
}
|
|
if err := svc.RemoveMember(ctx, "contrib", 1); err != nil {
|
|
t.Fatalf("RemoveMember: %v", err)
|
|
}
|
|
got, _ := svc.GetMembership(ctx, "contrib", 1)
|
|
if got != nil {
|
|
t.Fatalf("contributor should be soft-deleted, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_RemoveMember_ReturnsNotFound(t *testing.T) {
|
|
svc, _ := newServiceWithRepo()
|
|
if err := svc.RemoveMember(context.Background(), "ghost", 1); !errors.Is(err, ErrMembershipNotFound) {
|
|
t.Fatalf("want ErrMembershipNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The TOCTOU race the atomic helpers were introduced for: two Owner
|
|
// rows, demoting both must keep at least one. Sequentially via the
|
|
// service the second call must observe the post-first-demote state
|
|
// and refuse with ErrLastOwner. (True concurrent demotes are
|
|
// exercised at the repo layer with a real DB; here we pin the
|
|
// service-level contract.)
|
|
func TestTenantMemberService_UpdateRole_AtomicDemoteRejectsSecondLastOwner(t *testing.T) {
|
|
svc, repo := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
const tenantID uint64 = 7
|
|
for _, uid := range []string{"a", "b"} {
|
|
repo.rows = append(repo.rows, &types.TenantMember{
|
|
ID: uint64(len(repo.rows) + 1), UserID: uid, TenantID: tenantID,
|
|
Role: types.TenantRoleOwner, Status: types.TenantMemberStatusActive,
|
|
})
|
|
}
|
|
if err := svc.UpdateRole(ctx, "a", tenantID, types.TenantRoleViewer); err != nil {
|
|
t.Fatalf("first demote should succeed, got %v", err)
|
|
}
|
|
if err := svc.UpdateRole(ctx, "b", tenantID, types.TenantRoleViewer); !errors.Is(err, ErrLastOwner) {
|
|
t.Fatalf("second demote must hit ErrLastOwner, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantMemberService_RemoveMember_AtomicRemoveRejectsSecondLastOwner(t *testing.T) {
|
|
svc, repo := newServiceWithRepo()
|
|
ctx := context.Background()
|
|
const tenantID uint64 = 7
|
|
for _, uid := range []string{"a", "b"} {
|
|
repo.rows = append(repo.rows, &types.TenantMember{
|
|
ID: uint64(len(repo.rows) + 1), UserID: uid, TenantID: tenantID,
|
|
Role: types.TenantRoleOwner, Status: types.TenantMemberStatusActive,
|
|
})
|
|
}
|
|
if err := svc.RemoveMember(ctx, "a", tenantID); err != nil {
|
|
t.Fatalf("first remove should succeed, got %v", err)
|
|
}
|
|
if err := svc.RemoveMember(ctx, "b", tenantID); !errors.Is(err, ErrLastOwner) {
|
|
t.Fatalf("second remove must hit ErrLastOwner, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantRole_HasPermission(t *testing.T) {
|
|
cases := []struct {
|
|
caller types.TenantRole
|
|
required types.TenantRole
|
|
want bool
|
|
}{
|
|
{types.TenantRoleOwner, types.TenantRoleAdmin, true},
|
|
{types.TenantRoleAdmin, types.TenantRoleOwner, false},
|
|
{types.TenantRoleContributor, types.TenantRoleViewer, true},
|
|
{types.TenantRoleViewer, types.TenantRoleContributor, false},
|
|
{types.TenantRole("bogus"), types.TenantRoleViewer, false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.caller.HasPermission(c.required); got != c.want {
|
|
t.Errorf("HasPermission(%s, %s) = %v, want %v", c.caller, c.required, got, c.want)
|
|
}
|
|
}
|
|
}
|