Go · 10460 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package actionspolicy_test
4
5 import (
6 "context"
7 "errors"
8 "strings"
9 "testing"
10
11 "github.com/jackc/pgx/v5/pgtype"
12 "github.com/jackc/pgx/v5/pgxpool"
13
14 actionspolicy "github.com/tenseleyFlow/shithub/internal/actions/policy"
15 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
16 "github.com/tenseleyFlow/shithub/internal/actions/trigger"
17 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
19 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21 )
22
23 const policyFixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
24 "AAAAAAAAAAAAAAAA$" +
25 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
26
27 type policyFx struct {
28 ctx context.Context
29 pool *pgxpool.Pool
30 owner usersdb.User
31 writeUser usersdb.User
32 readUser usersdb.User
33 outsider usersdb.User
34 suspended usersdb.User
35 siteAdmin usersdb.User
36 orgOwner usersdb.User
37 repo reposdb.Repo
38 orgRepo reposdb.Repo
39 }
40
41 func setupPolicyFx(t *testing.T) policyFx {
42 t.Helper()
43 pool := dbtest.NewTestDB(t)
44 ctx := context.Background()
45 uq := usersdb.New()
46 mkUser := func(name string) usersdb.User {
47 t.Helper()
48 u, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
49 Username: name, DisplayName: name, PasswordHash: policyFixtureHash,
50 })
51 if err != nil {
52 t.Fatalf("CreateUser %s: %v", name, err)
53 }
54 return u
55 }
56 owner := mkUser("owner")
57 writeUser := mkUser("writer")
58 readUser := mkUser("reader")
59 outsider := mkUser("outsider")
60 suspended := mkUser("suspended")
61 siteAdmin := mkUser("siteadmin")
62 orgOwner := mkUser("orgowner")
63 if _, err := pool.Exec(ctx, `UPDATE users SET suspended_at = now() WHERE id = $1`, suspended.ID); err != nil {
64 t.Fatalf("suspend user: %v", err)
65 }
66 if _, err := pool.Exec(ctx, `UPDATE users SET is_site_admin = true WHERE id = $1`, siteAdmin.ID); err != nil {
67 t.Fatalf("site admin user: %v", err)
68 }
69 rq := reposdb.New()
70 repo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
71 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
72 Name: "demo",
73 DefaultBranch: "trunk",
74 Visibility: reposdb.RepoVisibilityPublic,
75 })
76 if err != nil {
77 t.Fatalf("CreateRepo: %v", err)
78 }
79 if _, err := pool.Exec(ctx, `INSERT INTO repo_collaborators (repo_id, user_id, role, added_by_user_id) VALUES ($1, $2, 'write', $3), ($1, $4, 'read', $3)`,
80 repo.ID, writeUser.ID, owner.ID, readUser.ID); err != nil {
81 t.Fatalf("insert collaborators: %v", err)
82 }
83 org, err := orgsdb.New().CreateOrg(ctx, pool, orgsdb.CreateOrgParams{
84 Slug: "acme",
85 DisplayName: "Acme",
86 CreatedByUserID: pgtype.Int8{Int64: orgOwner.ID, Valid: true},
87 })
88 if err != nil {
89 t.Fatalf("CreateOrg: %v", err)
90 }
91 if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, org.ID, orgOwner.ID); err != nil {
92 t.Fatalf("insert org owner: %v", err)
93 }
94 orgRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
95 OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
96 Name: "org-demo",
97 DefaultBranch: "trunk",
98 Visibility: reposdb.RepoVisibilityPublic,
99 })
100 if err != nil {
101 t.Fatalf("CreateRepo org: %v", err)
102 }
103 return policyFx{
104 ctx: ctx,
105 pool: pool,
106 owner: owner,
107 writeUser: writeUser,
108 readUser: readUser,
109 outsider: outsider,
110 suspended: suspended,
111 siteAdmin: siteAdmin,
112 orgOwner: orgOwner,
113 repo: repo,
114 orgRepo: orgRepo,
115 }
116 }
117
118 func TestEvaluateTrigger_TrustMatrix(t *testing.T) {
119 t.Parallel()
120 f := setupPolicyFx(t)
121 deps := actionspolicy.Deps{Pool: f.pool}
122 tests := []struct {
123 name string
124 repo reposdb.Repo
125 actorID int64
126 event trigger.EventKind
127 wantAllow bool
128 wantApproval bool
129 wantErr error
130 }{
131 {name: "owner push runs", repo: f.repo, actorID: f.owner.ID, event: trigger.EventPush, wantAllow: true},
132 {name: "write collaborator push runs", repo: f.repo, actorID: f.writeUser.ID, event: trigger.EventPush, wantAllow: true},
133 {name: "org owner push runs", repo: f.orgRepo, actorID: f.orgOwner.ID, event: trigger.EventPush, wantAllow: true},
134 {name: "read collaborator pr pauses for approval", repo: f.repo, actorID: f.readUser.ID, event: trigger.EventPullRequest, wantAllow: true, wantApproval: true},
135 {name: "outsider pr pauses for approval", repo: f.repo, actorID: f.outsider.ID, event: trigger.EventPullRequest, wantAllow: true, wantApproval: true},
136 {name: "outsider push denied", repo: f.repo, actorID: f.outsider.ID, event: trigger.EventPush, wantErr: actionspolicy.ErrUnauthorized},
137 {name: "suspended actor denied", repo: f.repo, actorID: f.suspended.ID, event: trigger.EventPush, wantErr: actionspolicy.ErrUnauthorized},
138 {name: "site admin write does not bypass repo role", repo: f.repo, actorID: f.siteAdmin.ID, event: trigger.EventPush, wantErr: actionspolicy.ErrUnauthorized},
139 }
140 for _, tt := range tests {
141 tt := tt
142 t.Run(tt.name, func(t *testing.T) {
143 dec, err := actionspolicy.EvaluateTrigger(f.ctx, deps, actionspolicy.TriggerRequest{
144 Repo: tt.repo,
145 EventKind: string(tt.event),
146 ActorUserID: tt.actorID,
147 })
148 if tt.wantErr != nil {
149 if !errors.Is(err, tt.wantErr) {
150 t.Fatalf("err=%v, want %v (decision=%+v)", err, tt.wantErr, dec)
151 }
152 return
153 }
154 if err != nil {
155 t.Fatalf("EvaluateTrigger: %v", err)
156 }
157 if dec.Allow != tt.wantAllow || dec.NeedApproval != tt.wantApproval {
158 t.Fatalf("decision=%+v, want allow=%t approval=%t", dec, tt.wantAllow, tt.wantApproval)
159 }
160 })
161 }
162 }
163
164 func TestEvaluateTrigger_PullRequestApprovalPolicyCanAllowImmediateRun(t *testing.T) {
165 t.Parallel()
166 f := setupPolicyFx(t)
167 if _, err := actionsdb.New().UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
168 RepoID: f.repo.ID,
169 ActionsEnabled: actionsdb.ActionsPolicyStateInherit,
170 RequirePrApproval: pgtype.Bool{Bool: false, Valid: true},
171 }); err != nil {
172 t.Fatalf("UpsertActionsRepoPolicy: %v", err)
173 }
174
175 dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
176 Repo: f.repo,
177 EventKind: string(trigger.EventPullRequest),
178 ActorUserID: f.outsider.ID,
179 })
180 if err != nil {
181 t.Fatalf("EvaluateTrigger: %v", err)
182 }
183 if !dec.Allow || dec.NeedApproval {
184 t.Fatalf("decision=%+v, want immediate PR run", dec)
185 }
186 }
187
188 func TestEvaluateTrigger_DeniesArchivedAndDisabledRepos(t *testing.T) {
189 t.Parallel()
190 f := setupPolicyFx(t)
191 archived := f.repo
192 archived.IsArchived = true
193 if dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
194 Repo: archived,
195 EventKind: string(trigger.EventPush),
196 ActorUserID: f.owner.ID,
197 }); !errors.Is(err, actionspolicy.ErrUnauthorized) || dec.Allow {
198 t.Fatalf("archived decision=%+v err=%v", dec, err)
199 }
200
201 if _, err := actionsdb.New().UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
202 RepoID: f.repo.ID,
203 ActionsEnabled: actionsdb.ActionsPolicyStateDisabled,
204 }); err != nil {
205 t.Fatalf("UpsertActionsRepoPolicy: %v", err)
206 }
207 if dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
208 Repo: f.repo,
209 EventKind: string(trigger.EventPush),
210 ActorUserID: f.owner.ID,
211 }); !errors.Is(err, actionspolicy.ErrActionsDisabled) || dec.Allow {
212 t.Fatalf("disabled decision=%+v err=%v", dec, err)
213 }
214 }
215
216 func TestEvaluateTrigger_SiteDisableOverridesRepoEnable(t *testing.T) {
217 t.Parallel()
218 f := setupPolicyFx(t)
219 q := actionsdb.New()
220 if _, err := q.UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
221 RepoID: f.repo.ID,
222 ActionsEnabled: actionsdb.ActionsPolicyStateEnabled,
223 }); err != nil {
224 t.Fatalf("UpsertActionsRepoPolicy: %v", err)
225 }
226 if _, err := f.pool.Exec(f.ctx, `UPDATE actions_site_policy SET actions_enabled = false WHERE id = true`); err != nil {
227 t.Fatalf("disable site policy: %v", err)
228 }
229
230 dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
231 Repo: f.repo,
232 EventKind: string(trigger.EventPush),
233 ActorUserID: f.owner.ID,
234 })
235 if !errors.Is(err, actionspolicy.ErrActionsDisabled) || dec.Allow || dec.Policy.ActionsEnabled {
236 t.Fatalf("site-disabled decision=%+v err=%v", dec, err)
237 }
238 }
239
240 func TestEvaluateTrigger_EnforcesQueueAndActorCaps(t *testing.T) {
241 t.Parallel()
242 f := setupPolicyFx(t)
243 q := actionsdb.New()
244 if _, err := q.UpsertActionsRepoPolicy(f.ctx, f.pool, actionsdb.UpsertActionsRepoPolicyParams{
245 RepoID: f.repo.ID,
246 ActionsEnabled: actionsdb.ActionsPolicyStateInherit,
247 MaxRepoQueuedRuns: pgtype.Int4{Int32: 1, Valid: true},
248 ActorTriggerLimitPerHour: pgtype.Int4{Int32: 1, Valid: true},
249 }); err != nil {
250 t.Fatalf("UpsertActionsRepoPolicy: %v", err)
251 }
252 if _, err := q.InsertWorkflowRun(f.ctx, f.pool, actionsdb.InsertWorkflowRunParams{
253 RepoID: f.repo.ID,
254 RunIndex: 1,
255 WorkflowFile: ".shithub/workflows/ci.yml",
256 WorkflowName: "CI",
257 HeadSha: strings.Repeat("a", 40),
258 HeadRef: "refs/heads/trunk",
259 Event: actionsdb.WorkflowRunEventPush,
260 EventPayload: []byte("{}"),
261 ActorUserID: pgtype.Int8{Int64: f.owner.ID, Valid: true},
262 NeedApproval: false,
263 }); err != nil {
264 t.Fatalf("InsertWorkflowRun: %v", err)
265 }
266 dec, err := actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
267 Repo: f.repo,
268 EventKind: string(trigger.EventPush),
269 ActorUserID: f.owner.ID,
270 })
271 if !errors.Is(err, actionspolicy.ErrRepoQueuedCap) || dec.Allow {
272 t.Fatalf("queue cap decision=%+v err=%v", dec, err)
273 }
274 if _, err := f.pool.Exec(f.ctx, `
275 UPDATE workflow_runs
276 SET status = 'completed',
277 conclusion = 'success',
278 started_at = now(),
279 completed_at = now()
280 WHERE repo_id = $1`,
281 f.repo.ID); err != nil {
282 t.Fatalf("complete queued run: %v", err)
283 }
284 dec, err = actionspolicy.EvaluateTrigger(f.ctx, actionspolicy.Deps{Pool: f.pool}, actionspolicy.TriggerRequest{
285 Repo: f.repo,
286 EventKind: string(trigger.EventPush),
287 ActorUserID: f.owner.ID,
288 })
289 if !errors.Is(err, actionspolicy.ErrActorRateLimit) || dec.Allow {
290 t.Fatalf("actor cap decision=%+v err=%v", dec, err)
291 }
292 }
293