@@ -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 | +} |