package idear_test import ( "errors" "fmt" "strings" "testing" "time" "amadan.net/rastrillo/idear" "amadan.net/rastrillo/idear/internal/ideartest" ) // ---------------------------------------------------------------- New func TestNew_RequiresDB(t *testing.T) { if _, err := idear.New(idear.Config{}); err == nil { t.Fatal("idear.New with no DB returned no error; a Roster over a nil handle panics on first use") } } func TestNew_Defaults(t *testing.T) { h := ideartest.New(t) if got := h.Roster.InviteTTL(); got != 7*24*time.Hour { t.Errorf("default InviteTTL = %v, want 7 days", got) } if h.Roster.OpenSignUp() { t.Error("OpenSignUp defaults to true; an instance must be invite-only unless it says otherwise") } } // TestErrorClasses pins what a handler switches on. // // A sentinel that belongs to no class falls through to the default arm // of the obvious taxonomy — ErrInvalid 400, ErrForbidden 403, // ErrNotFound 404, default 500 — and is rendered as a server error. Two // of this package's sentinels were exactly that before this test // existed, including ErrLastOwner, which is its most // security-relevant refusal. func TestErrorClasses(t *testing.T) { cases := []struct { name string err error is []error // classes and identities errors.Is MUST find isNot []error // and ones it must NOT }{ { name: "ErrLastOwner is a refusal, rendered 403", err: idear.ErrLastOwner, is: []error{idear.ErrLastOwner, idear.ErrForbidden}, isNot: []error{idear.ErrInvalid, idear.ErrNotFound}, }, { // The specific reason must stay distinguishable, or the // ordering TestSetRole_AuthorityFloorComesFirst asserts // becomes untestable. name: "an ordinary ErrForbidden is not ErrLastOwner", err: idear.ErrForbidden, is: []error{idear.ErrForbidden}, isNot: []error{idear.ErrLastOwner, idear.ErrInvalid}, }, { name: "ErrInvalidRole is malformed input, rendered 400", err: idear.ErrInvalidRole, is: []error{idear.ErrInvalidRole, idear.ErrInvalid}, isNot: []error{idear.ErrForbidden, idear.ErrInvalidEmail, idear.ErrInvalidSubject}, }, { name: "ErrInvalidEmail is malformed input, rendered 400", err: idear.ErrInvalidEmail, is: []error{idear.ErrInvalidEmail, idear.ErrInvalid}, isNot: []error{idear.ErrForbidden, idear.ErrInvalidRole, idear.ErrInvalidSubject}, }, { name: "ErrInvalidSubject is malformed input, rendered 400", err: idear.ErrInvalidSubject, is: []error{idear.ErrInvalidSubject, idear.ErrInvalid}, isNot: []error{idear.ErrForbidden, idear.ErrInvalidRole, idear.ErrInvalidEmail}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // Wrapped the way the store wraps them, not bare: that is // the form a handler actually receives. wrapped := fmt.Errorf("%w: some context", tc.err) for _, target := range tc.is { if !errors.Is(wrapped, target) { t.Errorf("errors.Is(%v, %v) = false, want true", wrapped, target) } } for _, target := range tc.isNot { if errors.Is(wrapped, target) { t.Errorf("errors.Is(%v, %v) = true, want false", wrapped, target) } } }) } } // -------------------------------------------------------- IsEmpty/Claim func TestClaim_FirstArrivalBecomesOwner(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() empty, err := h.Roster.IsEmpty(ctx) if err != nil { t.Fatalf("IsEmpty: %v", err) } if !empty { t.Fatal("a fresh roster is not empty") } m, err := h.Roster.Claim(ctx, "subject-1", " Founder@Example.TEST ", " Founder ") if err != nil { t.Fatalf("Claim: %v", err) } if m.Role != idear.RoleOwner { t.Errorf("claimant's role = %q, want owner", m.Role) } if m.Email != "founder@example.test" { t.Errorf("claimant's email = %q, want it trimmed and lowercased", m.Email) } if m.Name != "Founder" { t.Errorf("claimant's name = %q, want it trimmed", m.Name) } if got := h.TheOwner(); got.ID != m.ID { t.Errorf("stored owner id = %d, want %d", got.ID, m.ID) } } func TestClaim_SecondClaimRefused(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() if _, err := h.Roster.Claim(ctx, "first", "first@example.test", "First"); err != nil { t.Fatalf("first Claim: %v", err) } _, err := h.Roster.Claim(ctx, "second", "second@example.test", "Second") if !errors.Is(err, idear.ErrOwnerExists) { t.Fatalf("second Claim error = %v, want ErrOwnerExists", err) } if n := h.CountMembers(); n != 1 { t.Errorf("roster holds %d members after a refused claim, want 1", n) } } // TestClaim_DeactivatedRosterDoesNotReopenTheClaim is the "zero rows, // not zero ACTIVE rows" rule. A roster whose every member has been // deactivated still holds their rows — and their history, and every // AuthorID in the app's own tables pointing at them. Reopening the // claim would hand a stranger Owner of all of it. func TestClaim_DeactivatedRosterDoesNotReopenTheClaim(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() h.Deactivated(idear.RoleOwner) h.Deactivated(idear.RoleMember) empty, err := h.Roster.IsEmpty(ctx) if err != nil { t.Fatalf("IsEmpty: %v", err) } if empty { t.Error("IsEmpty says a roster of deactivated members is empty; it counts active rows, not rows") } _, err = h.Roster.Claim(ctx, "stranger", "stranger@example.test", "Stranger") if !errors.Is(err, idear.ErrOwnerExists) { t.Fatalf("Claim on an all-deactivated roster = %v, want ErrOwnerExists", err) } if n := h.CountMembers(); n != 2 { t.Errorf("roster holds %d members, want the 2 dormant ones and no stranger", n) } } func TestClaim_RequiresSubject(t *testing.T) { h := ideartest.New(t) _, err := h.Roster.Claim(h.Ctx(), " ", "nobody@example.test", "") if !errors.Is(err, idear.ErrInvalidSubject) || !errors.Is(err, idear.ErrInvalid) { t.Fatalf("Claim with a blank subject = %v, want ErrInvalidSubject in the ErrInvalid class", err) } if n := h.CountMembers(); n != 0 { t.Errorf("roster holds %d members after a refused claim, want 0", n) } } // --------------------------------------------------------------- Invite // TestInvite_RefusesOwnerRoleForEveryActor is the flat rule: no actor, // at any rank, on any path, mints an owner-role invitation. Ownership // moves only by Transfer. func TestInvite_RefusesOwnerRoleForEveryActor(t *testing.T) { h := ideartest.New(t) owner := h.Owner() admin := h.Member(idear.RoleAdmin) member := h.Member(idear.RoleMember) for _, actor := range []*idear.Member{owner, admin, member} { _, _, err := h.Roster.Invite(h.Ctx(), actor, "new@example.test", idear.RoleOwner) if !errors.Is(err, idear.ErrForbidden) { t.Errorf("a %s inviting at owner: err = %v, want ErrForbidden", actor.Role, err) } } if invs, err := h.Roster.PendingInvitations(h.Ctx()); err != nil { t.Fatalf("PendingInvitations: %v", err) } else if len(invs) != 0 { t.Errorf("%d invitations were minted despite every call being refused", len(invs)) } } func TestInvite_RankRules(t *testing.T) { h := ideartest.New(t) owner := h.Owner() admin := h.Member(idear.RoleAdmin) member := h.Member(idear.RoleMember) gone := h.Deactivated(idear.RoleAdmin) cases := []struct { name string actor *idear.Member role idear.Role ok bool }{ {"owner invites admin", owner, idear.RoleAdmin, true}, {"owner invites member", owner, idear.RoleMember, true}, {"admin invites member", admin, idear.RoleMember, true}, // An admin who could mint a peer admin has escalated: MayActOn // refuses acting on an equal rank, so the new admin would be // beyond the granter's reach and every other admin's too. {"admin invites admin", admin, idear.RoleAdmin, false}, {"member invites member", member, idear.RoleMember, false}, // The actor is re-read inside the transaction, so a privilege // revoked a moment ago is revoked for this call too. {"deactivated admin invites member", gone, idear.RoleMember, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, _, err := h.Roster.Invite(h.Ctx(), tc.actor, "invitee@example.test", tc.role) if tc.ok && err != nil { t.Fatalf("Invite = %v, want success", err) } if !tc.ok && !errors.Is(err, idear.ErrForbidden) { t.Fatalf("Invite = %v, want ErrForbidden", err) } }) } } // TestInvite_RejectsBlankEmail is the sibling of // TestInvite_RejectsUnknownRole: the role field and the address field // of one invitation form must classify the same way, or a mistyped // address renders 500 while a mistyped role renders 400. func TestInvite_RejectsBlankEmail(t *testing.T) { h := ideartest.New(t) owner := h.Owner() for _, email := range []string{"", " ", "\t\n"} { _, _, err := h.Roster.Invite(h.Ctx(), owner, email, idear.RoleMember) if !errors.Is(err, idear.ErrInvalidEmail) || !errors.Is(err, idear.ErrInvalid) { t.Errorf("Invite(%q) = %v, want ErrInvalidEmail in the ErrInvalid class", email, err) } } if invs, err := h.Roster.PendingInvitations(h.Ctx()); err != nil { t.Fatalf("PendingInvitations: %v", err) } else if len(invs) != 0 { t.Errorf("%d invitations were minted from a blank address", len(invs)) } } func TestInvite_RejectsUnknownRole(t *testing.T) { h := ideartest.New(t) owner := h.Owner() _, _, err := h.Roster.Invite(h.Ctx(), owner, "invitee@example.test", idear.Role("superuser")) if !errors.Is(err, idear.ErrInvalidRole) { t.Fatalf("Invite at role \"superuser\" = %v, want ErrInvalidRole", err) } } // TestInvite_StoresOnlyTheHash pins the "hashed at rest" rule from the // design's §4: the plaintext exists in the return value and the emitted // link, and nowhere in the table. func TestInvite_StoresOnlyTheHash(t *testing.T) { h := ideartest.New(t) owner := h.Owner() inv, token, err := h.Roster.Invite(h.Ctx(), owner, "Invitee@Example.TEST", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if token == "" { t.Fatal("Invite returned an empty token") } stored := h.Invitation(inv.ID) if stored.TokenHash == token { t.Error("the plaintext token is what is stored; only its digest may be") } if strings.Contains(stored.TokenHash, token) || strings.Contains(stored.Email, token) { t.Error("the plaintext token appears in a stored column") } if stored.Email != "invitee@example.test" { t.Errorf("stored email = %q, want it normalised", stored.Email) } if stored.InvitedBy != owner.ID { t.Errorf("InvitedBy = %d, want the inviting owner %d", stored.InvitedBy, owner.ID) } // The digest is only demonstrably the RIGHT digest if the token // still redeems — asserting sha256(token) here would just restate // the implementation back to itself. if _, err := h.Roster.Accept(h.Ctx(), token, "invitee-subject", "Invitee"); err != nil { t.Fatalf("Accept with the returned token: %v", err) } } func TestInvite_ExpiresAtHonoursTTL(t *testing.T) { h := ideartest.NewWith(t, idear.Config{InviteTTL: time.Minute}) owner := h.Owner() before := time.Now().UTC() inv, _, err := h.Roster.Invite(h.Ctx(), owner, "invitee@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } after := time.Now().UTC() if inv.ExpiresAt.Before(before.Add(time.Minute)) || inv.ExpiresAt.After(after.Add(time.Minute)) { t.Errorf("ExpiresAt = %v, want about %v", inv.ExpiresAt, before.Add(time.Minute)) } } // --------------------------------------------------------------- Revoke func TestRevoke_KillsAPendingInvitation(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() inv, token, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if err := h.Roster.Revoke(ctx, owner, inv.ID); err != nil { t.Fatalf("Revoke: %v", err) } if h.Invitation(inv.ID).RevokedAt == nil { t.Error("RevokedAt is still NULL after Revoke") } if _, err := h.Roster.Accept(ctx, token, "invitee-subject", "Invitee"); !errors.Is(err, idear.ErrNoInvitation) { t.Errorf("Accept of a revoked invitation = %v, want ErrNoInvitation", err) } } // TestInvite_SupersedesAPendingInvitationForTheSameAddress pins the // only semantics an admin would predict: re-inviting REPLACES. // // Without it two live invitations for one address coexist and the two // identity paths disagree about which one is spent. keymail redeems // the OLDEST redeemable row, so re-inviting Alice at a corrected // higher role is silently ignored — the admin's members page says // Admin and Alice arrives as Member — and a corrected LOWER role // leaves the stale higher one live for her to escalate past the // correction. password spends whichever of the several links the // invitee happens to click. // // Both redemption paths are driven, in two instances, because "both // land at the new role" is the claim and one path proving it is half // an answer. func TestInvite_SupersedesAPendingInvitationForTheSameAddress(t *testing.T) { // The mistake being corrected: Member, then Admin. The second // invite spells the address differently on purpose — invitations // are stored normalised, so the supersede must match on the // normalised form or it silently does nothing. const address = "alice@example.test" const retyped = " Alice@Example.Test " t.Run("password redeems the new token", func(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() stale, staleToken, err := h.Roster.Invite(ctx, owner, address, idear.RoleMember) if err != nil { t.Fatalf("first Invite: %v", err) } fresh, freshToken, err := h.Roster.Invite(ctx, owner, retyped, idear.RoleAdmin) if err != nil { t.Fatalf("second Invite: %v", err) } // Exactly one invitation is live, and it is the new one. pending, err := h.Roster.PendingInvitations(ctx) if err != nil { t.Fatalf("PendingInvitations: %v", err) } if len(pending) != 1 || pending[0].ID != fresh.ID { t.Fatalf("pending invitations = %+v, want only the re-invitation %d", pending, fresh.ID) } // The stale one is REVOKED — not accepted, not deleted: the // record of what was offered and withdrawn stays true. switch old := h.Invitation(stale.ID); { case old.RevokedAt == nil: t.Error("re-inviting left the earlier invitation live; the invitee can still land at the superseded role") case old.AcceptedAt != nil: t.Error("the superseded invitation was marked accepted; nobody accepted it") } // And the stale token is spent as far as anyone holding it is // concerned. if _, err := h.Roster.Accept(ctx, staleToken, "alice-subject", "Alice"); !errors.Is(err, idear.ErrNoInvitation) { t.Fatalf("the superseded token was redeemable: %v", err) } m, err := h.Roster.Accept(ctx, freshToken, "alice-subject", "Alice") if err != nil { t.Fatalf("Accept of the re-invitation: %v", err) } if m.Role != idear.RoleAdmin { t.Errorf("password admitted %s at %s, want the corrected admin", address, m.Role) } }) t.Run("keymail redeems by address", func(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() if _, _, err := h.Roster.Invite(ctx, owner, address, idear.RoleMember); err != nil { t.Fatalf("first Invite: %v", err) } if _, _, err := h.Roster.Invite(ctx, owner, retyped, idear.RoleAdmin); err != nil { t.Fatalf("second Invite: %v", err) } // Authorize is keymail's whole admission: a verified address, // no token, and acceptByAddress underneath it taking the // oldest redeemable row. Before the supersede that row was the // stale Member one. if !h.Roster.Authorize(address) { t.Fatal("Authorize refused an address holding a live invitation") } var admitted []idear.Member if err := h.DB.G.Where("subject = ?", address).Find(&admitted).Error; err != nil { t.Fatalf("looking up the admitted member: %v", err) } if len(admitted) != 1 { t.Fatalf("roster holds %d rows for %q, want 1", len(admitted), address) } if admitted[0].Role != idear.RoleAdmin { t.Errorf("keymail admitted %s at %s, want the corrected admin", address, admitted[0].Role) } }) } func TestRevoke_Refusals(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() member := h.Member(idear.RoleMember) inv, _, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if err := h.Roster.Revoke(ctx, member, inv.ID); !errors.Is(err, idear.ErrForbidden) { t.Errorf("a member revoking = %v, want ErrForbidden", err) } if err := h.Roster.Revoke(ctx, owner, inv.ID+9999); !errors.Is(err, idear.ErrNoInvitation) { t.Errorf("revoking an unknown id = %v, want ErrNoInvitation", err) } if err := h.Roster.Revoke(ctx, owner, inv.ID); err != nil { t.Fatalf("Revoke: %v", err) } if err := h.Roster.Revoke(ctx, owner, inv.ID); !errors.Is(err, idear.ErrNoInvitation) { t.Errorf("revoking twice = %v, want ErrNoInvitation", err) } } // --------------------------------------------------------------- Accept func TestAccept_AdmitsAtTheInvitedRole(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() inv, token, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleAdmin) if err != nil { t.Fatalf("Invite: %v", err) } m, err := h.Roster.Accept(ctx, token, "invitee-subject", "Invitee") if err != nil { t.Fatalf("Accept: %v", err) } if m.Role != idear.RoleAdmin { t.Errorf("admitted at %q, want the invited role admin", m.Role) } if m.Email != "invitee@example.test" { t.Errorf("member email = %q, want the invitation's address", m.Email) } if h.Invitation(inv.ID).AcceptedAt == nil { t.Error("AcceptedAt is still NULL after a successful Accept") } } func TestAccept_Refusals(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() t.Run("unknown token", func(t *testing.T) { _, err := h.Roster.Accept(ctx, "not-a-token", "s1", "One") if !errors.Is(err, idear.ErrNoInvitation) { t.Fatalf("err = %v, want ErrNoInvitation", err) } }) t.Run("empty token", func(t *testing.T) { _, err := h.Roster.Accept(ctx, "", "s2", "Two") if !errors.Is(err, idear.ErrNoInvitation) { t.Fatalf("err = %v, want ErrNoInvitation", err) } }) t.Run("replay", func(t *testing.T) { _, token, err := h.Roster.Invite(ctx, owner, "replay@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if _, err := h.Roster.Accept(ctx, token, "replay-1", "One"); err != nil { t.Fatalf("first Accept: %v", err) } _, err = h.Roster.Accept(ctx, token, "replay-2", "Two") if !errors.Is(err, idear.ErrNoInvitation) { t.Fatalf("replayed Accept = %v, want ErrNoInvitation", err) } if _, err := h.Roster.BySubject(ctx, "replay-2"); !errors.Is(err, idear.ErrNotFound) { t.Fatalf("the replayer was admitted anyway: %v", err) } }) t.Run("expired", func(t *testing.T) { inv, token, err := h.Roster.Invite(ctx, owner, "expired@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } h.Expire(inv.ID) _, err = h.Roster.Accept(ctx, token, "expired-subject", "Expired") if !errors.Is(err, idear.ErrNoInvitation) { t.Fatalf("Accept of an expired invitation = %v, want ErrNoInvitation", err) } if h.Invitation(inv.ID).AcceptedAt != nil { t.Error("the expired invitation was consumed by the refused Accept") } }) t.Run("blank subject", func(t *testing.T) { inv, token, err := h.Roster.Invite(ctx, owner, "blank@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } _, err = h.Roster.Accept(ctx, token, " ", "Blank") if !errors.Is(err, idear.ErrInvalidSubject) || !errors.Is(err, idear.ErrInvalid) { t.Fatalf("Accept with a blank subject = %v, want ErrInvalidSubject in the ErrInvalid class", err) } if h.Invitation(inv.ID).AcceptedAt != nil { t.Error("the invitation was consumed by the refused Accept") } }) } // TestAccept_RefusesAForgedOwnerInvitation is defence in depth. Invite // cannot mint one, so a row carrying role='owner' is corruption — and // corruption must not become a second Owner. func TestAccept_RefusesAForgedOwnerInvitation(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() inv, token, err := h.Roster.Invite(ctx, owner, "forged@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } // Forge it behind the store's back, the way a stray migration or a // hand-edited database would. if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", inv.ID). Update("role", idear.RoleOwner).Error; err != nil { t.Fatalf("forging the row: %v", err) } if _, err := h.Roster.Accept(ctx, token, "forged-subject", "Forged"); !errors.Is(err, idear.ErrForbidden) { t.Fatalf("Accept of an owner-role invitation = %v, want ErrForbidden", err) } h.TheOwner() // still exactly one, and it is not the forger if _, err := h.Roster.BySubject(ctx, "forged-subject"); !errors.Is(err, idear.ErrNotFound) { t.Errorf("the forger was admitted: %v", err) } if h.Invitation(inv.ID).AcceptedAt != nil { t.Error("the forged invitation was consumed; the whole transaction should have rolled back") } } // TestAccept_ReturningMemberCollidesAndRollsBack pins the design's §4 // corollary: a deactivated member cannot be readmitted by a fresh // invitation, because Subject is unique and their row is still there. // Reactivate is the path — and the invitation must survive the failure // intact rather than being burnt by it. func TestAccept_ReturningMemberCollidesAndRollsBack(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() gone := h.Deactivated(idear.RoleMember) inv, token, err := h.Roster.Invite(ctx, owner, gone.Email, idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if _, err := h.Roster.Accept(ctx, token, gone.Subject, gone.Name); err == nil { t.Fatal("Accept re-admitted a deactivated subject; the unique index should have refused it") } if h.Invitation(inv.ID).AcceptedAt != nil { t.Error("the invitation was consumed by the failed Accept; the transaction did not roll back") } if h.Reload(gone.ID).Active() { t.Error("the failed Accept reactivated the dormant row") } } // -------------------------------------------------------------- SetRole func TestSetRole_RefusesOwner(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() admin := h.Member(idear.RoleAdmin) // As the new role: ownership moves only by Transfer. if err := h.Roster.SetRole(ctx, owner, admin, idear.RoleOwner); !errors.Is(err, idear.ErrForbidden) { t.Errorf("SetRole(..., owner) = %v, want ErrForbidden", err) } if got := h.Reload(admin.ID).Role; got != idear.RoleAdmin { t.Errorf("target's role is now %q; a refused SetRole wrote anyway", got) } h.TheOwner() // As the target: the Owner's role changes only by Transfer either, // and this is an invariant rather than a permission — no rank can // do it, so ErrLastOwner is the honest answer, not ErrForbidden. if err := h.Roster.SetRole(ctx, admin, owner, idear.RoleMember); !errors.Is(err, idear.ErrLastOwner) { t.Errorf("SetRole demoting the owner = %v, want ErrLastOwner", err) } if got := h.Reload(owner.ID).Role; got != idear.RoleOwner { t.Errorf("owner's role is now %q", got) } } // TestSetRole_AuthorityFloorComesFirst pins the ORDER of the checks. A // plain Member aiming at the Owner must be refused for lacking // authority — ErrForbidden — and not handed ErrLastOwner, which would // answer "which row is the owner" to someone who had not established // they may ask anything at all. func TestSetRole_AuthorityFloorComesFirst(t *testing.T) { h := ideartest.New(t) owner := h.Owner() member := h.Member(idear.RoleMember) err := h.Roster.SetRole(h.Ctx(), member, owner, idear.RoleMember) if !errors.Is(err, idear.ErrForbidden) { t.Fatalf("a member demoting the owner = %v, want ErrForbidden", err) } if errors.Is(err, idear.ErrLastOwner) { t.Error("a member was told which row is the owner") } } func TestSetRole_Matrix(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() admin := h.Member(idear.RoleAdmin) member := h.Member(idear.RoleMember) other := h.Member(idear.RoleMember) if err := h.Roster.SetRole(ctx, owner, member, idear.RoleAdmin); err != nil { t.Fatalf("owner promoting a member to admin: %v", err) } if got := h.Reload(member.ID).Role; got != idear.RoleAdmin { t.Errorf("role = %q, want admin", got) } // An admin minting a peer admin is an escalation: MayActOn refuses // acting on an equal rank, so the new admin is beyond reach. if err := h.Roster.SetRole(ctx, admin, other, idear.RoleAdmin); !errors.Is(err, idear.ErrForbidden) { t.Errorf("admin promoting to admin = %v, want ErrForbidden", err) } if err := h.Roster.SetRole(ctx, admin, other, idear.RoleMember); err != nil { t.Errorf("admin setting a member to member: %v", err) } if err := h.Roster.SetRole(ctx, admin, admin, idear.RoleMember); !errors.Is(err, idear.ErrForbidden) { t.Errorf("admin demoting themselves = %v, want ErrForbidden", err) } if err := h.Roster.SetRole(ctx, owner, other, idear.Role("root")); !errors.Is(err, idear.ErrInvalidRole) { t.Errorf("SetRole to \"root\" = %v, want ErrInvalidRole", err) } if err := h.Roster.SetRole(ctx, owner, &idear.Member{ID: 9999}, idear.RoleMember); !errors.Is(err, idear.ErrNotFound) { t.Errorf("SetRole on an unknown id = %v, want ErrNotFound", err) } } // ----------------------------------------------------------- Deactivate // TestDeactivate_NeverRemovesTheOwner is the invariant that keeps an // instance administrable. It is checked against the row inside the // transaction, which is what also makes Transfer racing Deactivate safe // from this side. func TestDeactivate_NeverRemovesTheOwner(t *testing.T) { h := ideartest.New(t) owner := h.Owner() admin := h.Member(idear.RoleAdmin) if err := h.Roster.Deactivate(h.Ctx(), admin, owner); !errors.Is(err, idear.ErrLastOwner) { t.Fatalf("an admin deactivating the owner = %v, want ErrLastOwner", err) } if !h.Reload(owner.ID).Active() { t.Fatal("the owner was deactivated") } // The owner cannot do it to themselves either. if err := h.Roster.Deactivate(h.Ctx(), owner, owner); err == nil { t.Error("the owner deactivated themselves") } if !h.Reload(owner.ID).Active() { t.Error("the owner was deactivated") } } func TestDeactivate_Matrix(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() admin := h.Member(idear.RoleAdmin) otherAdmin := h.Member(idear.RoleAdmin) member := h.Member(idear.RoleMember) if err := h.Roster.Deactivate(ctx, admin, member); err != nil { t.Fatalf("admin deactivating a member: %v", err) } if h.Reload(member.ID).Active() { t.Error("the member is still active") } // Idempotent: removing someone already removed is not an error. if err := h.Roster.Deactivate(ctx, admin, member); err != nil { t.Errorf("deactivating twice = %v, want nil", err) } if err := h.Roster.Deactivate(ctx, admin, otherAdmin); !errors.Is(err, idear.ErrForbidden) { t.Errorf("admin deactivating a peer admin = %v, want ErrForbidden", err) } if err := h.Roster.Deactivate(ctx, member, otherAdmin); !errors.Is(err, idear.ErrForbidden) { t.Errorf("a deactivated member deactivating anyone = %v, want ErrForbidden", err) } if err := h.Roster.Deactivate(ctx, owner, admin); err != nil { t.Errorf("owner deactivating an admin: %v", err) } } // ----------------------------------------------------------- Reactivate func TestReactivate_RestoresPriorAccessAndNoMore(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() gone := h.Deactivated(idear.RoleAdmin) if err := h.Roster.Reactivate(ctx, owner, gone); err != nil { t.Fatalf("Reactivate: %v", err) } back := h.Reload(gone.ID) if !back.Active() { t.Fatal("the member is still deactivated") } if back.Role != idear.RoleAdmin { t.Errorf("role after reactivation = %q, want the admin they were", back.Role) } // Idempotent. if err := h.Roster.Reactivate(ctx, owner, back); err != nil { t.Errorf("reactivating an active member = %v, want nil", err) } h.TheOwner() } func TestReactivate_Refusals(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() member := h.Member(idear.RoleMember) admin := h.Member(idear.RoleAdmin) goneAdmin := h.Deactivated(idear.RoleAdmin) if err := h.Roster.Reactivate(ctx, member, goneAdmin); !errors.Is(err, idear.ErrForbidden) { t.Errorf("a member reactivating = %v, want ErrForbidden", err) } if err := h.Roster.Reactivate(ctx, admin, goneAdmin); !errors.Is(err, idear.ErrForbidden) { t.Errorf("an admin reactivating a peer admin = %v, want ErrForbidden", err) } if h.Reload(goneAdmin.ID).Active() { t.Error("a refused Reactivate wrote anyway") } } // ------------------------------------------------------------- Transfer func TestTransfer_MovesOwnershipExactlyOnce(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() admin := h.Member(idear.RoleAdmin) if err := h.Roster.Transfer(ctx, owner, admin); err != nil { t.Fatalf("Transfer: %v", err) } if got := h.TheOwner(); got.ID != admin.ID { t.Errorf("owner is member %d, want %d", got.ID, admin.ID) } if got := h.Reload(owner.ID).Role; got != idear.RoleAdmin { t.Errorf("outgoing owner's role = %q, want admin", got) } // The outgoing owner is no longer the owner, so they cannot // transfer again. if err := h.Roster.Transfer(ctx, owner, h.Member(idear.RoleMember)); !errors.Is(err, idear.ErrForbidden) { t.Errorf("a former owner transferring = %v, want ErrForbidden", err) } h.TheOwner() } func TestTransfer_Refusals(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() admin := h.Member(idear.RoleAdmin) gone := h.Deactivated(idear.RoleAdmin) if err := h.Roster.Transfer(ctx, admin, owner); !errors.Is(err, idear.ErrForbidden) { t.Errorf("a non-owner transferring = %v, want ErrForbidden", err) } if err := h.Roster.Transfer(ctx, owner, owner); !errors.Is(err, idear.ErrForbidden) { t.Errorf("transferring to oneself = %v, want ErrForbidden", err) } // The one that matters: a deactivated Owner is an instance nobody // can administer and nobody can be promoted into. if err := h.Roster.Transfer(ctx, owner, gone); !errors.Is(err, idear.ErrForbidden) { t.Errorf("transferring to a deactivated member = %v, want ErrForbidden", err) } if err := h.Roster.Transfer(ctx, owner, &idear.Member{ID: 9999}); !errors.Is(err, idear.ErrNotFound) { t.Errorf("transferring to an unknown id = %v, want ErrNotFound", err) } if got := h.TheOwner(); got.ID != owner.ID { t.Errorf("ownership moved despite every transfer being refused (now %d)", got.ID) } } // -------------------------------------------------------------- Lookups func TestBySubjectAndByID(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() gone := h.Deactivated(idear.RoleMember) got, err := h.Roster.BySubject(ctx, owner.Subject) if err != nil || got.ID != owner.ID { t.Fatalf("BySubject = (%v, %v), want the owner", got, err) } // Deactivated members resolve: the middleware has to tell a // dormant member from a stranger in order to log the difference, // even though it answers both with the app's 404. got, err = h.Roster.BySubject(ctx, gone.Subject) if err != nil { t.Fatalf("BySubject on a deactivated member = %v, want the row", err) } if got.Active() { t.Error("the deactivated member reads as active") } if _, err := h.Roster.BySubject(ctx, "nobody"); !errors.Is(err, idear.ErrNotFound) { t.Errorf("BySubject on a stranger = %v, want ErrNotFound", err) } // An empty subject is a request with no session, never a wildcard. if _, err := h.Roster.BySubject(ctx, " "); !errors.Is(err, idear.ErrNotFound) { t.Errorf("BySubject(\"\") = %v, want ErrNotFound", err) } if _, err := h.Roster.ByID(ctx, 9999); !errors.Is(err, idear.ErrNotFound) { t.Errorf("ByID on an unknown id = %v, want ErrNotFound", err) } if m, err := h.Roster.ByID(ctx, owner.ID); err != nil || m.Role != idear.RoleOwner { t.Errorf("ByID = (%v, %v), want the owner", m, err) } } func TestMembers_RankedAndIncludesDeactivated(t *testing.T) { h := ideartest.New(t) member := h.Member(idear.RoleMember) admin := h.Member(idear.RoleAdmin) owner := h.Owner() gone := h.Deactivated(idear.RoleMember) got, err := h.Roster.Members(h.Ctx()) if err != nil { t.Fatalf("Members: %v", err) } want := []int64{owner.ID, admin.ID, member.ID, gone.ID} if len(got) != len(want) { t.Fatalf("Members returned %d rows, want %d — deactivated members must be listed so they can be restored", len(got), len(want)) } for i, id := range want { if got[i].ID != id { t.Errorf("Members[%d].ID = %d, want %d (owner, then admins, then members, then by id)", i, got[i].ID, id) } } } func TestPendingInvitations_HidesSpentAndExpired(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() live, _, err := h.Roster.Invite(ctx, owner, "live@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } revoked, _, err := h.Roster.Invite(ctx, owner, "revoked@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if err := h.Roster.Revoke(ctx, owner, revoked.ID); err != nil { t.Fatalf("Revoke: %v", err) } accepted, token, err := h.Roster.Invite(ctx, owner, "accepted@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if _, err := h.Roster.Accept(ctx, token, "accepted-subject", "Accepted"); err != nil { t.Fatalf("Accept: %v", err) } expired, _, err := h.Roster.Invite(ctx, owner, "expired@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } h.Expire(expired.ID) got, err := h.Roster.PendingInvitations(ctx) if err != nil { t.Fatalf("PendingInvitations: %v", err) } if len(got) != 1 || got[0].ID != live.ID { ids := make([]int64, len(got)) for i, inv := range got { ids[i] = inv.ID } t.Fatalf("PendingInvitations returned %v, want only the live one (%d); revoked=%d accepted=%d expired=%d", ids, live.ID, revoked.ID, accepted.ID, expired.ID) } } // TestExpiryComparisonMatchesGo pins the assumption underneath Accept's // CAS, which is load-bearing and invisible. // // Timestamps reach SQLite as text, in the driver's own rendering of // time.Time.String(), in columns whose declared type is DATETIME. The // CAS asks the database "expires_at > ?", and that is a TEXT // comparison. It answers correctly only because the rendering is // fixed-width through the seconds and sorts lexicographically the way // the instants sort — including across the boundary where the // fractional part is trimmed away entirely. // // Two things break it, and both are pinned below. // // A monotonic clock reading: String() appends " m=+0.0001" to any time // that still carries one, so the stored text no longer has the shape // the comparison assumes. .UTC() strips it; time.Now() alone does not, // and neither does Add on top of it. // // A non-UTC location, which is the worse of the two because it is // silent and seasonal: String() renders the zone, so a value built in // Europe/Dublin stores as "... +0100 IST" and sorts against a UTC row // by the offset characters — an answer with no relationship to which // instant is later. An invitation minted in summer could outlive one // minted in winter regardless of its actual expiry. func TestExpiryComparisonMatchesGo(t *testing.T) { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() inv, _, err := h.Roster.Invite(ctx, owner, "clock@example.test", idear.RoleMember) if err != nil { t.Fatalf("Invite: %v", err) } if strings.Contains(inv.ExpiresAt.String(), "m=") { t.Fatalf("ExpiresAt carries a monotonic reading (%s); stored, it would break every expiry comparison", inv.ExpiresAt.String()) } if inv.ExpiresAt.Location() != time.UTC { t.Fatalf("ExpiresAt is in %v, not UTC (%s); String() renders the zone, so a non-UTC row "+ "sorts against a UTC one by its offset characters and the expiry comparison is meaningless", inv.ExpiresAt.Location(), inv.ExpiresAt.String()) } stored := h.Invitation(inv.ID) if stored.ExpiresAt.Location() != time.UTC { t.Fatalf("ExpiresAt came back in %v, not UTC (%s)", stored.ExpiresAt.Location(), stored.ExpiresAt.String()) } if !stored.ExpiresAt.Equal(inv.ExpiresAt) { t.Errorf("ExpiresAt round-tripped as %v, want %v", stored.ExpiresAt, inv.ExpiresAt) } base := stored.ExpiresAt for _, delta := range []time.Duration{ -time.Hour, -time.Second, -time.Millisecond, -time.Nanosecond, 0, time.Nanosecond, time.Millisecond, time.Second, time.Hour, } { probe := base.Add(delta) var n int64 if err := h.DB.G.Model(&idear.Invitation{}). Where("id = ? AND expires_at > ?", inv.ID, probe).Count(&n).Error; err != nil { t.Fatalf("counting at delta %v: %v", delta, err) } want := int64(0) if base.After(probe) { want = 1 } if n != want { t.Errorf("delta %v: SQL says %d row(s) with expires_at > probe, Go says %v", delta, n, want == 1) } } // And the trimmed-fraction boundary explicitly: a whole-second // timestamp renders without a fractional part at all, so the // character that follows the seconds changes from '.' to ' '. whole := base.Truncate(time.Second) if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", inv.ID). Update("expires_at", whole).Error; err != nil { t.Fatalf("storing a whole-second expiry: %v", err) } for _, delta := range []time.Duration{-time.Nanosecond, time.Nanosecond} { probe := whole.Add(delta) var n int64 if err := h.DB.G.Model(&idear.Invitation{}). Where("id = ? AND expires_at > ?", inv.ID, probe).Count(&n).Error; err != nil { t.Fatalf("counting at delta %v: %v", delta, err) } want := int64(0) if whole.After(probe) { want = 1 } if n != want { t.Errorf("whole-second boundary, delta %v: SQL says %d, Go says %v", delta, n, want == 1) } } }