// SPDX-License-Identifier: MPL-2.0 package organizations import ( "context" "crypto/sha256" "errors" "slices" "strings" "testing" "time" ) type roleInvitationRepositoryStub struct { repositoryStub audit AuditEvent ownerRole string created int } func (r *roleInvitationRepositoryStub) CreateInvitationWithRoles(_ context.Context, invitation Invitation, ownerRole string, audit AuditEvent) error { r.created++ r.invitation, r.audit, r.ownerRole = invitation, audit, ownerRole return nil } func (r *roleInvitationRepositoryStub) AcceptInvitationWithRoles(_ context.Context, _ [32]byte, userID, ownerRole string, _ time.Time, audit AuditEvent) error { r.acceptedUser, r.ownerRole, r.audit = userID, ownerRole, audit return nil } func TestInvitationRoleNamesAreBoundedAndUnambiguous(t *testing.T) { input := []string{"buyer", "billing"} roles, err := (Invitation{DirectRoles: input}).RoleNames() if err != nil || !slices.Equal(roles, []string{"billing", "buyer"}) || !slices.Equal(input, []string{"buyer", "billing"}) { t.Fatalf("roles=%v input=%v err=%v", roles, input, err) } for _, invitation := range []Invitation{ {DirectRole: "owner", DirectRoles: []string{"buyer"}}, {DirectRoles: []string{"buyer", "buyer"}}, {DirectRoles: []string{"not a role"}}, {DirectRoles: make([]string, 17)}, } { if _, err := invitation.RoleNames(); err == nil { t.Fatalf("invalid roles accepted=%+v", invitation) } } if roles, err = (Invitation{DirectRole: "buyer"}).RoleNames(); err != nil || !slices.Equal(roles, []string{"buyer"}) { t.Fatalf("legacy=%v err=%v", roles, err) } } func TestRoleInvitationServicePreservesOwnerRequirementAndRequest(t *testing.T) { r := &roleInvitationRepositoryStub{} service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true}) if err != nil { t.Fatal(err) } input := InviteWithAccess{OrganizationID: "organization-123", InvitedByUserID: "owner-12345678", Email: " Member@example.test ", DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour, RequestID: "request-invite"} raw, invitation, err := service.InviteWithAccess(t.Context(), input) if err != nil { t.Fatal(err) } if invitation.Digest != sha256.Sum256([]byte(raw)) || invitation.RequiredOwnerRole != "owner" || r.ownerRole != "owner" || r.audit.RequestID != input.RequestID || !slices.Equal(invitation.DirectRoles, []string{"billing", "buyer"}) || invitation.Email != "member@example.test" { t.Fatalf("invitation or audit mismatch: %+v %+v", invitation, r.audit) } if !slices.Equal(input.DirectRoles, []string{"buyer", "billing"}) { t.Fatal("caller roles mutated") } if err = service.AcceptInvitation(t.Context(), raw, "member-12345678"); err != nil || r.ownerRole != "owner" || r.acceptedUser != "member-12345678" { t.Fatalf("accept=%v owner=%q user=%q", err, r.ownerRole, r.acceptedUser) } input.RequestID = strings.Repeat("x", 129) if _, _, err = service.InviteWithAccess(t.Context(), input); err == nil || r.created != 1 { t.Fatal("oversized request accepted") } if _, err = New(r, Options{OwnerManagedInvitations: true}); err == nil { t.Fatal("owner-managed service without owner accepted") } } func TestRoleInvitationHasNoPartialLegacyFallback(t *testing.T) { for _, input := range []InviteWithAccess{ {DirectRoles: []string{"buyer", "billing"}}, {DirectRole: "owner"}, {}, } { r := &repositoryStub{} service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true}) if err != nil { t.Fatal(err) } input.OrganizationID, input.InvitedByUserID, input.Email, input.Lifetime = "organization-123", "owner-12345678", "member@example.test", time.Hour if _, _, err = service.InviteWithAccess(t.Context(), input); !errors.Is(err, ErrRoleInvitationUnsupported) || r.invitation.ID != "" { t.Fatalf("legacy fallback=%v", err) } r.invitation = Invitation{OrganizationID: input.OrganizationID, ID: "invitation-1234", RequiredOwnerRole: "owner"} if err = service.AcceptInvitation(t.Context(), strings.Repeat("a", 43), "member-12345678"); !errors.Is(err, ErrRoleInvitationUnsupported) || r.acceptedUser != "" { t.Fatalf("legacy acceptance fallback=%v", err) } } }