Files
xinmi/internal/application/service/tenant_member_test.go
T

646 lines
21 KiB
Go
Raw Normal View History

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)
}
}
}