@@ -0,0 +1,318 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package policy_test |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "testing" |
| 8 | + |
| 9 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 10 | +) |
| 11 | + |
| 12 | +// The matrix is built from three orthogonal axes: actor archetype, |
| 13 | +// resource shape, and Action. We enumerate every combination and |
| 14 | +// assert the verdict so a future refactor that breaks one cell fails |
| 15 | +// loudly. |
| 16 | + |
| 17 | +type actorKind int |
| 18 | + |
| 19 | +const ( |
| 20 | + actorAnonymous actorKind = iota |
| 21 | + actorOwner |
| 22 | + actorCollabRead |
| 23 | + actorCollabTriage |
| 24 | + actorCollabWrite |
| 25 | + actorCollabMaintain |
| 26 | + actorCollabAdmin |
| 27 | + actorUnrelated |
| 28 | + actorSuspendedOwner |
| 29 | + actorSuspendedCollabWrite |
| 30 | + actorSiteAdmin |
| 31 | +) |
| 32 | + |
| 33 | +func (k actorKind) String() string { |
| 34 | + return [...]string{ |
| 35 | + "anonymous", "owner", |
| 36 | + "collab-read", "collab-triage", "collab-write", "collab-maintain", "collab-admin", |
| 37 | + "unrelated", "suspended-owner", "suspended-collab-write", "site-admin", |
| 38 | + }[k] |
| 39 | +} |
| 40 | + |
| 41 | +type repoKind int |
| 42 | + |
| 43 | +const ( |
| 44 | + repoPublic repoKind = iota |
| 45 | + repoPrivate |
| 46 | + repoArchivedPublic |
| 47 | + repoArchivedPrivate |
| 48 | + repoDeletedPublic |
| 49 | +) |
| 50 | + |
| 51 | +func (k repoKind) String() string { |
| 52 | + return [...]string{"public", "private", "archived-public", "archived-private", "deleted-public"}[k] |
| 53 | +} |
| 54 | + |
| 55 | +const ( |
| 56 | + ownerID int64 = 100 |
| 57 | + otherID int64 = 200 |
| 58 | + repoIDV int64 = 1000 |
| 59 | +) |
| 60 | + |
| 61 | +func makeActor(k actorKind) policy.Actor { |
| 62 | + switch k { |
| 63 | + case actorAnonymous: |
| 64 | + return policy.AnonymousActor() |
| 65 | + case actorOwner: |
| 66 | + return policy.UserActor(ownerID, "owner", false, false) |
| 67 | + case actorCollabRead, actorCollabTriage, actorCollabWrite, actorCollabMaintain, actorCollabAdmin: |
| 68 | + return policy.UserActor(otherID, "collab", false, false) |
| 69 | + case actorUnrelated: |
| 70 | + return policy.UserActor(otherID+1, "stranger", false, false) |
| 71 | + case actorSuspendedOwner: |
| 72 | + return policy.UserActor(ownerID, "owner", true, false) |
| 73 | + case actorSuspendedCollabWrite: |
| 74 | + return policy.UserActor(otherID, "collab", true, false) |
| 75 | + case actorSiteAdmin: |
| 76 | + return policy.UserActor(otherID+2, "admin", false, true) |
| 77 | + } |
| 78 | + return policy.AnonymousActor() |
| 79 | +} |
| 80 | + |
| 81 | +func makeRepo(k repoKind) policy.RepoRef { |
| 82 | + r := policy.RepoRef{ID: repoIDV, OwnerUserID: ownerID} |
| 83 | + switch k { |
| 84 | + case repoPublic: |
| 85 | + r.Visibility = "public" |
| 86 | + case repoPrivate: |
| 87 | + r.Visibility = "private" |
| 88 | + case repoArchivedPublic: |
| 89 | + r.Visibility = "public" |
| 90 | + r.IsArchived = true |
| 91 | + case repoArchivedPrivate: |
| 92 | + r.Visibility = "private" |
| 93 | + r.IsArchived = true |
| 94 | + case repoDeletedPublic: |
| 95 | + r.Visibility = "public" |
| 96 | + r.IsDeleted = true |
| 97 | + } |
| 98 | + return r |
| 99 | +} |
| 100 | + |
| 101 | +// fakeRoleResolver intercepts the cache layer so tests can assert role |
| 102 | +// outcomes without hitting Postgres. Each collab actorKind seeds a |
| 103 | +// specific role in the cache pre-Can. |
| 104 | +func ctxWithCollabRole(t *testing.T, kind actorKind) context.Context { |
| 105 | + t.Helper() |
| 106 | + ctx := policy.WithCache(context.Background()) |
| 107 | + role := map[actorKind]policy.Role{ |
| 108 | + actorCollabRead: policy.RoleRead, |
| 109 | + actorCollabTriage: policy.RoleTriage, |
| 110 | + actorCollabWrite: policy.RoleWrite, |
| 111 | + actorCollabMaintain: policy.RoleMaintain, |
| 112 | + actorCollabAdmin: policy.RoleAdmin, |
| 113 | + actorSuspendedCollabWrite: policy.RoleWrite, |
| 114 | + }[kind] |
| 115 | + if role != policy.RoleNone { |
| 116 | + policy.PrimeCacheForTest(ctx, otherID, repoIDV, role) |
| 117 | + } |
| 118 | + return ctx |
| 119 | +} |
| 120 | + |
| 121 | +// expect computes the canonical verdict for a (actorKind, repoKind, Action) |
| 122 | +// triple. The function below is the policy spec restated in plain Go; |
| 123 | +// if Can() and expect() ever disagree, one of them has a bug. |
| 124 | +// |
| 125 | +//nolint:gocyclo // exhaustive shape is by design. |
| 126 | +func expect(actor actorKind, repo repoKind, action policy.Action) bool { |
| 127 | + // Deleted repo → nothing. |
| 128 | + if repo == repoDeletedPublic { |
| 129 | + return false |
| 130 | + } |
| 131 | + isWrite := action != policy.ActionRepoRead && action != policy.ActionIssueRead && action != policy.ActionPullRead |
| 132 | + |
| 133 | + // Site admin can read everything (except deleted handled above). |
| 134 | + if actor == actorSiteAdmin && !isWrite { |
| 135 | + return true |
| 136 | + } |
| 137 | + |
| 138 | + // Suspended actors: writes always blocked. Reads against public repos |
| 139 | + // still allowed (matches the suspended-then-read public code path). |
| 140 | + if actor == actorSuspendedOwner || actor == actorSuspendedCollabWrite { |
| 141 | + if isWrite { |
| 142 | + return false |
| 143 | + } |
| 144 | + // fall through to the visibility check |
| 145 | + } |
| 146 | + |
| 147 | + isPrivate := repo == repoPrivate || repo == repoArchivedPrivate |
| 148 | + isArchived := repo == repoArchivedPublic || repo == repoArchivedPrivate |
| 149 | + |
| 150 | + // Anonymous + private → deny. |
| 151 | + if actor == actorAnonymous && isPrivate { |
| 152 | + return false |
| 153 | + } |
| 154 | + |
| 155 | + // Public repo reads: anyone. |
| 156 | + if !isWrite && !isPrivate { |
| 157 | + return true |
| 158 | + } |
| 159 | + |
| 160 | + // Compute role. |
| 161 | + var have policy.Role |
| 162 | + switch actor { |
| 163 | + case actorOwner, actorSuspendedOwner: |
| 164 | + have = policy.RoleAdmin |
| 165 | + case actorCollabRead: |
| 166 | + have = policy.RoleRead |
| 167 | + case actorCollabTriage: |
| 168 | + have = policy.RoleTriage |
| 169 | + case actorCollabWrite, actorSuspendedCollabWrite: |
| 170 | + have = policy.RoleWrite |
| 171 | + case actorCollabMaintain: |
| 172 | + have = policy.RoleMaintain |
| 173 | + case actorCollabAdmin: |
| 174 | + have = policy.RoleAdmin |
| 175 | + } |
| 176 | + |
| 177 | + // Archived: writes denied (covers owner too). |
| 178 | + if isArchived && isWrite { |
| 179 | + return false |
| 180 | + } |
| 181 | + |
| 182 | + // Login-only social actions. |
| 183 | + if action == policy.ActionStarCreate || action == policy.ActionForkCreate { |
| 184 | + return have != policy.RoleNone || (actor != actorAnonymous) |
| 185 | + } |
| 186 | + |
| 187 | + // Role check. |
| 188 | + want := mirrorMinRoleFor(action) |
| 189 | + if want == policy.RoleNone { |
| 190 | + return actor != actorAnonymous |
| 191 | + } |
| 192 | + return policy.RoleAtLeast(have, want) |
| 193 | +} |
| 194 | + |
| 195 | +// mirrorMinRoleFor mirrors policy.minRoleFor for test side. We could |
| 196 | +// expose the internal helper; keeping the mirror enforces that the |
| 197 | +// matrix knows about every action explicitly. |
| 198 | +func mirrorMinRoleFor(a policy.Action) policy.Role { |
| 199 | + switch a { |
| 200 | + case policy.ActionRepoRead, policy.ActionIssueRead, policy.ActionPullRead: |
| 201 | + return policy.RoleRead |
| 202 | + case policy.ActionIssueClose, policy.ActionIssueLabel, policy.ActionIssueAssign: |
| 203 | + return policy.RoleTriage |
| 204 | + case policy.ActionRepoWrite, policy.ActionPullCreate, policy.ActionPullReview, policy.ActionPullClose, |
| 205 | + policy.ActionIssueCreate, policy.ActionIssueComment: |
| 206 | + return policy.RoleWrite |
| 207 | + case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches: |
| 208 | + return policy.RoleMaintain |
| 209 | + case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators, |
| 210 | + policy.ActionRepoArchive, policy.ActionRepoDelete, policy.ActionRepoTransfer, policy.ActionRepoVisibility, |
| 211 | + policy.ActionPullMerge: |
| 212 | + return policy.RoleAdmin |
| 213 | + case policy.ActionStarCreate, policy.ActionForkCreate: |
| 214 | + return policy.RoleNone |
| 215 | + } |
| 216 | + return policy.RoleAdmin |
| 217 | +} |
| 218 | + |
| 219 | +func TestCan_Matrix(t *testing.T) { |
| 220 | + t.Parallel() |
| 221 | + d := policy.Deps{} // pool nil OK; cache primes the role |
| 222 | + |
| 223 | + for _, ak := range []actorKind{ |
| 224 | + actorAnonymous, actorOwner, |
| 225 | + actorCollabRead, actorCollabTriage, actorCollabWrite, actorCollabMaintain, actorCollabAdmin, |
| 226 | + actorUnrelated, actorSuspendedOwner, actorSuspendedCollabWrite, actorSiteAdmin, |
| 227 | + } { |
| 228 | + for _, rk := range []repoKind{ |
| 229 | + repoPublic, repoPrivate, repoArchivedPublic, repoArchivedPrivate, repoDeletedPublic, |
| 230 | + } { |
| 231 | + for _, action := range policy.AllActions { |
| 232 | + ak, rk, action := ak, rk, action |
| 233 | + name := ak.String() + "/" + rk.String() + "/" + string(action) |
| 234 | + t.Run(name, func(t *testing.T) { |
| 235 | + t.Parallel() |
| 236 | + ctx := ctxWithCollabRole(t, ak) |
| 237 | + actor := makeActor(ak) |
| 238 | + repo := makeRepo(rk) |
| 239 | + got := policy.Can(ctx, d, actor, action, repo).Allow |
| 240 | + want := expect(ak, rk, action) |
| 241 | + if got != want { |
| 242 | + t.Errorf("Can(%s, %s, %s) = %v, want %v", |
| 243 | + ak, rk, action, got, want) |
| 244 | + } |
| 245 | + }) |
| 246 | + } |
| 247 | + } |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +func TestRoleAtLeast(t *testing.T) { |
| 252 | + t.Parallel() |
| 253 | + cases := []struct { |
| 254 | + have, want policy.Role |
| 255 | + ok bool |
| 256 | + }{ |
| 257 | + {policy.RoleAdmin, policy.RoleRead, true}, |
| 258 | + {policy.RoleAdmin, policy.RoleAdmin, true}, |
| 259 | + {policy.RoleWrite, policy.RoleAdmin, false}, |
| 260 | + {policy.RoleRead, policy.RoleTriage, false}, |
| 261 | + {policy.RoleNone, policy.RoleRead, false}, |
| 262 | + {policy.RoleRead, policy.RoleNone, false}, // RoleNone is meaningless as a target |
| 263 | + } |
| 264 | + for _, c := range cases { |
| 265 | + if got := policy.RoleAtLeast(c.have, c.want); got != c.ok { |
| 266 | + t.Errorf("RoleAtLeast(%q, %q) = %v, want %v", c.have, c.want, got, c.ok) |
| 267 | + } |
| 268 | + } |
| 269 | +} |
| 270 | + |
| 271 | +func TestMaybe404(t *testing.T) { |
| 272 | + t.Parallel() |
| 273 | + priv := policy.RepoRef{ID: 1, OwnerUserID: ownerID, Visibility: "private"} |
| 274 | + pub := policy.RepoRef{ID: 1, OwnerUserID: ownerID, Visibility: "public"} |
| 275 | + owner := policy.UserActor(ownerID, "o", false, false) |
| 276 | + stranger := policy.UserActor(otherID, "s", false, false) |
| 277 | + |
| 278 | + // Anonymous on private → 404 leak guard. |
| 279 | + if got := policy.Maybe404(policy.Decision{Allow: false}, priv, policy.AnonymousActor()); got != 404 { |
| 280 | + t.Errorf("anonymous on private: got %d, want 404", got) |
| 281 | + } |
| 282 | + // Stranger on private → 404 (don't reveal existence). |
| 283 | + if got := policy.Maybe404(policy.Decision{Allow: false}, priv, stranger); got != 404 { |
| 284 | + t.Errorf("stranger on private: got %d, want 404", got) |
| 285 | + } |
| 286 | + // Owner on private (e.g. archived push) → real 403. |
| 287 | + if got := policy.Maybe404(policy.Decision{Allow: false}, priv, owner); got != 403 { |
| 288 | + t.Errorf("owner deny on private: got %d, want 403", got) |
| 289 | + } |
| 290 | + // Public deny → 403 always. |
| 291 | + if got := policy.Maybe404(policy.Decision{Allow: false}, pub, policy.AnonymousActor()); got != 403 { |
| 292 | + t.Errorf("anonymous deny on public: got %d, want 403", got) |
| 293 | + } |
| 294 | + // Allow → 200 regardless of repo shape. |
| 295 | + if got := policy.Maybe404(policy.Decision{Allow: true}, priv, owner); got != 200 { |
| 296 | + t.Errorf("allow: got %d, want 200", got) |
| 297 | + } |
| 298 | +} |
| 299 | + |
| 300 | +func TestCacheInvalidate(t *testing.T) { |
| 301 | + t.Parallel() |
| 302 | + ctx := policy.WithCache(context.Background()) |
| 303 | + policy.PrimeCacheForTest(ctx, otherID, repoIDV, policy.RoleAdmin) |
| 304 | + |
| 305 | + // Confirm primed. |
| 306 | + d := policy.Deps{} |
| 307 | + actor := policy.UserActor(otherID, "u", false, false) |
| 308 | + repo := policy.RepoRef{ID: repoIDV, OwnerUserID: ownerID, Visibility: "private"} |
| 309 | + if !policy.Can(ctx, d, actor, policy.ActionRepoAdmin, repo).Allow { |
| 310 | + t.Fatalf("primed cache should grant admin") |
| 311 | + } |
| 312 | + |
| 313 | + // Invalidate; with no DB pool the next lookup falls through to RoleNone. |
| 314 | + policy.InvalidateRepo(ctx, repoIDV) |
| 315 | + if policy.Can(ctx, d, actor, policy.ActionRepoAdmin, repo).Allow { |
| 316 | + t.Errorf("after invalidate, admin should deny without role row") |
| 317 | + } |
| 318 | +} |