| 1 | package sheets |
| 2 | |
| 3 | import "testing" |
| 4 | |
| 5 | // The two-user isolation suite AGENTS.md requires. |
| 6 | // |
| 7 | // Every test here asks the same question from a different angle: can |
| 8 | // somebody reach a sheet nobody gave them? The answer must always be |
| 9 | // "they get exactly what a nonexistent sheet gives" — a 404, never a |
| 10 | // 403, because a 403 confirms the row exists and the set of ids that |
| 11 | // 403 is a map of the team's sheets. |
| 12 | |
| 13 | // guestOn shares a sheet with a fresh address at a role, and returns |
| 14 | // that member. The address is deliberately outside SHEETS_OWNERS, so |
| 15 | // the only way they reach anything is the grant itself. |
| 16 | func guestOn(t *testing.T, app *App, sheetID uint64, address string, role SheetRole) Member { |
| 17 | t.Helper() |
| 18 | m, err := app.grant(sheetID, address, role, signedInMember(t, app)) |
| 19 | if err != nil { |
| 20 | t.Fatalf("grant %s as %s: %v", address, role, err) |
| 21 | } |
| 22 | if TeamRole(m.TeamRole) != TeamGuest { |
| 23 | t.Fatalf("%s was minted as %q, want guest — a guest wrongly made a member "+ |
| 24 | "sees every sheet on the instance", address, m.TeamRole) |
| 25 | } |
| 26 | return m |
| 27 | } |
| 28 | |
| 29 | func TestAGuestSeesOnlyWhatTheyWereGranted(t *testing.T) { |
| 30 | app, cookies := signIn(t) |
| 31 | shared, _ := newSheet(t, app, cookies, "Shared with Ada") |
| 32 | private, _ := newSheet(t, app, cookies, "Not shared") |
| 33 | |
| 34 | ada := guestOn(t, app, shared.ID, "ada@elsewhere.example", SheetViewer) |
| 35 | |
| 36 | got, err := app.listSheets(ada) |
| 37 | if err != nil { |
| 38 | t.Fatalf("listSheets: %v", err) |
| 39 | } |
| 40 | if len(got) != 1 { |
| 41 | t.Fatalf("a guest granted one sheet sees %d: %+v", len(got), got) |
| 42 | } |
| 43 | if got[0].ID != shared.ID { |
| 44 | t.Fatalf("the guest sees sheet %d, want the one they were granted (%d)", got[0].ID, shared.ID) |
| 45 | } |
| 46 | if app.mayRead(private.ID, ada) { |
| 47 | t.Fatal("a guest can read a sheet nobody granted them") |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestAGuestWithNoGrantsSeesNothing(t *testing.T) { |
| 52 | app, cookies := signIn(t) |
| 53 | newSheet(t, app, cookies, "Team sheet") |
| 54 | |
| 55 | stranger, err := app.memberFor("nobody@elsewhere.example") |
| 56 | if err != nil { |
| 57 | t.Fatalf("memberFor: %v", err) |
| 58 | } |
| 59 | got, err := app.listSheets(stranger) |
| 60 | if err != nil { |
| 61 | t.Fatalf("listSheets: %v", err) |
| 62 | } |
| 63 | // Empty, not everything. The inner join is what makes this true; |
| 64 | // a left join here would hand a stranger the whole instance. |
| 65 | if len(got) != 0 { |
| 66 | t.Fatalf("a member with no grants sees %d sheets, want 0", len(got)) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // A colleague reaches every sheet — that is what one instance per team |
| 71 | // means, and it is the half the guest rule must not break. |
| 72 | func TestATeamMemberSeesEverySheet(t *testing.T) { |
| 73 | app, cookies := signIn(t) |
| 74 | one, _ := newSheet(t, app, cookies, "One") |
| 75 | two, _ := newSheet(t, app, cookies, "Two") |
| 76 | |
| 77 | colleague, err := app.memberFor("colleague@example.com") |
| 78 | if err != nil { |
| 79 | t.Fatalf("memberFor: %v", err) |
| 80 | } |
| 81 | if err := app.db.Model(&colleague).Update("team_role", string(TeamMember)).Error; err != nil { |
| 82 | t.Fatalf("promote: %v", err) |
| 83 | } |
| 84 | colleague.TeamRole = string(TeamMember) |
| 85 | |
| 86 | got, err := app.listSheets(colleague) |
| 87 | if err != nil { |
| 88 | t.Fatalf("listSheets: %v", err) |
| 89 | } |
| 90 | if len(got) != 2 { |
| 91 | t.Fatalf("a team member sees %d of 2 sheets", len(got)) |
| 92 | } |
| 93 | if !app.mayWrite(one.ID, colleague) || !app.mayWrite(two.ID, colleague) { |
| 94 | t.Fatal("a team member cannot write to a team sheet") |
| 95 | } |
| 96 | // But not owner: sharing is not something every colleague inherits. |
| 97 | if app.mayShare(one.ID, colleague) { |
| 98 | t.Fatal("a team member can change who else can read a sheet they were never given") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestRolesAreOrdered(t *testing.T) { |
| 103 | cases := []struct { |
| 104 | have, want SheetRole |
| 105 | allowed bool |
| 106 | }{ |
| 107 | {SheetOwner, SheetEditor, true}, |
| 108 | {SheetOwner, SheetViewer, true}, |
| 109 | {SheetEditor, SheetEditor, true}, |
| 110 | {SheetEditor, SheetOwner, false}, |
| 111 | {SheetCommenter, SheetViewer, true}, |
| 112 | {SheetCommenter, SheetEditor, false}, |
| 113 | {SheetViewer, SheetViewer, true}, |
| 114 | {SheetViewer, SheetCommenter, false}, |
| 115 | // An unknown role ranks below every real one, so a row written |
| 116 | // by a newer build grants nothing rather than everything. |
| 117 | {SheetRole("admin"), SheetViewer, false}, |
| 118 | {SheetRole(""), SheetViewer, false}, |
| 119 | } |
| 120 | for _, c := range cases { |
| 121 | if got := c.have.Allows(c.want); got != c.allowed { |
| 122 | t.Errorf("%q.Allows(%q) = %v, want %v", c.have, c.want, got, c.allowed) |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func TestRevokeRemovesAccess(t *testing.T) { |
| 128 | app, cookies := signIn(t) |
| 129 | s, _ := newSheet(t, app, cookies, "Shared then not") |
| 130 | ada := guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| 131 | |
| 132 | if !app.mayRead(s.ID, ada) { |
| 133 | t.Fatal("the grant did not take") |
| 134 | } |
| 135 | if err := app.revoke(s.ID, ada.ID); err != nil { |
| 136 | t.Fatalf("revoke: %v", err) |
| 137 | } |
| 138 | if app.mayRead(s.ID, ada) { |
| 139 | t.Fatal("a revoked guest can still read") |
| 140 | } |
| 141 | // Revoking again is not an error: the caller wanted them not to |
| 142 | // have access, and they do not. |
| 143 | if err := app.revoke(s.ID, ada.ID); err != nil { |
| 144 | t.Fatalf("revoking twice: %v", err) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // Re-sharing changes the role rather than failing on the unique index, |
| 149 | // which is what somebody doing it expects. |
| 150 | func TestRegrantingChangesTheRole(t *testing.T) { |
| 151 | app, cookies := signIn(t) |
| 152 | s, _ := newSheet(t, app, cookies, "Promote me") |
| 153 | ada := guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| 154 | |
| 155 | if _, err := app.grant(s.ID, "ada@elsewhere.example", SheetEditor, signedInMember(t, app)); err != nil { |
| 156 | t.Fatalf("re-grant: %v", err) |
| 157 | } |
| 158 | role, ok := app.roleFor(s.ID, ada) |
| 159 | if !ok || role != SheetEditor { |
| 160 | t.Fatalf("after re-granting, role = %q (%v), want editor", role, ok) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestGrantRefusesRubbish(t *testing.T) { |
| 165 | app, cookies := signIn(t) |
| 166 | s, _ := newSheet(t, app, cookies, "Sheet") |
| 167 | by := signedInMember(t, app) |
| 168 | |
| 169 | for _, tc := range []struct { |
| 170 | address string |
| 171 | role SheetRole |
| 172 | }{ |
| 173 | {"", SheetViewer}, |
| 174 | {" ", SheetViewer}, |
| 175 | {"not-an-address", SheetViewer}, |
| 176 | {"ada@elsewhere.example", SheetRole("admin")}, |
| 177 | {"ada@elsewhere.example", SheetRole("")}, |
| 178 | } { |
| 179 | if _, err := app.grant(s.ID, tc.address, tc.role, by); err == nil { |
| 180 | t.Errorf("grant(%q, %q) was accepted", tc.address, tc.role) |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // The creator owns what they made, written as a Grant rather than |
| 186 | // inferred from Sheet.CreatedBy — two sources of truth for one question |
| 187 | // is how the share screen ends up unable to hand ownership on. |
| 188 | func TestTheCreatorOwnsTheSheet(t *testing.T) { |
| 189 | app, cookies := signIn(t) |
| 190 | s, _ := newSheet(t, app, cookies, "Mine") |
| 191 | me := signedInMember(t, app) |
| 192 | |
| 193 | role, ok := app.roleFor(s.ID, me) |
| 194 | if !ok || role != SheetOwner { |
| 195 | t.Fatalf("the creator's role is %q (%v), want owner", role, ok) |
| 196 | } |
| 197 | if !app.mayShare(s.ID, me) { |
| 198 | t.Fatal("the creator cannot share their own sheet") |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // Sharing has to make somebody able to sign in, or the grant is a row |
| 203 | // nobody can ever exercise. This is what the app did between the grants |
| 204 | // table landing and App.Admits existing. |
| 205 | func TestASharedWithGuestMaySignIn(t *testing.T) { |
| 206 | app, cookies := signIn(t) |
| 207 | s, _ := newSheet(t, app, cookies, "Shared") |
| 208 | |
| 209 | if app.Admits("ada@elsewhere.example") { |
| 210 | t.Fatal("a stranger is admitted before any share") |
| 211 | } |
| 212 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| 213 | if !app.Admits("ada@elsewhere.example") { |
| 214 | t.Fatal("a guest who holds a grant is refused sign-in; the share would be unusable") |
| 215 | } |
| 216 | // Case and padding must not decide who gets in. |
| 217 | if !app.Admits(" Ada@Elsewhere.Example ") { |
| 218 | t.Fatal("admission is case-sensitive; the same person would be refused") |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestAdmissionIsFailClosed(t *testing.T) { |
| 223 | app, _ := signIn(t) |
| 224 | for _, addr := range []string{"", " ", "nobody@elsewhere.example"} { |
| 225 | if app.Admits(addr) { |
| 226 | t.Errorf("Admits(%q) = true", addr) |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // A grant row whose role this build cannot read must grant nothing, not |
| 232 | // fall through to team standing — falling through would silently |
| 233 | // upgrade an unreadable grant to full access. |
| 234 | func TestAnUnreadableGrantGrantsNothing(t *testing.T) { |
| 235 | app, cookies := signIn(t) |
| 236 | s, _ := newSheet(t, app, cookies, "Sheet") |
| 237 | me := signedInMember(t, app) |
| 238 | |
| 239 | if err := app.db.Model(&Grant{}). |
| 240 | Where("sheet_id = ? AND member_id = ?", s.ID, me.ID). |
| 241 | Update("role", "from-the-future").Error; err != nil { |
| 242 | t.Fatalf("corrupt the grant: %v", err) |
| 243 | } |
| 244 | if _, ok := app.roleFor(s.ID, me); ok { |
| 245 | t.Fatal("a grant with an unrecognised role still granted access") |
| 246 | } |
| 247 | } |
| 248 | |