tenseleyflow/shithub / ad9035a

Browse files

Keep downgraded secret teams read-only

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ad9035ad7eedda1942382db389be66d0ea6e3887
Parents
cda85ba
Tree
c88592d

4 changed files

StatusFile+-
M internal/web/handlers/orgs/teams.go 77 19
M internal/web/handlers/orgs/teams_test.go 143 0
M internal/web/handlers/repo/settings_branches_test.go 46 0
M internal/web/templates/orgs/team_view.html 4 0
internal/web/handlers/orgs/teams.gomodified
@@ -196,6 +196,27 @@ func teamsNoticeMessage(code string) string {
196196
 	}
197197
 }
198198
 
199
+func (h *Handlers) secretTeamWriteNotice(ctx context.Context, orgID int64, team orgsdb.Team) (string, error) {
200
+	if team.Privacy != orgsdb.TeamPrivacySecret {
201
+		return "", nil
202
+	}
203
+	decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, orgID, entitlements.FeatureOrgSecretTeams)
204
+	if err != nil {
205
+		return "", err
206
+	}
207
+	if decision.Allowed {
208
+		return "", nil
209
+	}
210
+	switch decision.Reason {
211
+	case entitlements.ReasonBillingActionNeeded:
212
+		return "secret-teams-billing", nil
213
+	case entitlements.ReasonEnterpriseContactSales:
214
+		return "secret-teams-enterprise", nil
215
+	default:
216
+		return "secret-teams-upgrade", nil
217
+	}
218
+}
219
+
199220
 // teamView renders /{org}/teams/{teamSlug}. Members + repo access.
200221
 // Secret teams 404 for non-members + non-owners.
201222
 func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) {
@@ -224,30 +245,47 @@ func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) {
224245
 	memberCandidates := h.teamMemberCandidates(r.Context(), org.ID, team.ID)
225246
 	repoCandidates := h.teamRepoCandidates(r.Context(), org.ID, team.ID)
226247
 	isOwner := false
248
+	canExpandTeam := false
249
+	secretTeamWritesDisabledMessage := ""
227250
 	if !viewer.IsAnonymous() {
228251
 		isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
252
+		canExpandTeam = isOwner
253
+		if isOwner {
254
+			noticeCode, nerr := h.secretTeamWriteNotice(r.Context(), org.ID, team)
255
+			if nerr != nil {
256
+				h.d.Logger.WarnContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", nerr)
257
+				canExpandTeam = false
258
+				secretTeamWritesDisabledMessage = "Secret team changes are temporarily unavailable."
259
+			} else if noticeCode != "" {
260
+				canExpandTeam = false
261
+				secretTeamWritesDisabledMessage = teamsNoticeMessage(noticeCode)
262
+			}
263
+		}
229264
 	}
230265
 	navCounts := h.orgNavCounts(r.Context(), org.ID, -1)
231266
 	if err := h.d.Render.RenderPage(w, r, "orgs/team_view", map[string]any{
232
-		"Title":            string(org.Slug) + "/" + string(team.Slug),
233
-		"CSRFToken":        middleware.CSRFTokenForRequest(r),
234
-		"Org":              org,
235
-		"AvatarURL":        "/avatars/" + url.PathEscape(string(org.Slug)),
236
-		"ActiveOrgNav":     "teams",
237
-		"Team":             team,
238
-		"TeamDisplayName":  teamDisplayName(team),
239
-		"TeamPath":         h.teamPath(org, team),
240
-		"TeamPrivacy":      string(team.Privacy),
241
-		"TeamIsSecret":     team.Privacy == orgsdb.TeamPrivacySecret,
242
-		"ChildTeams":       childItems,
243
-		"Members":          members,
244
-		"MemberCandidates": memberCandidates,
245
-		"Repos":            repos,
246
-		"RepoCandidates":   repoCandidates,
247
-		"RepoCount":        navCounts.RepoCount,
248
-		"MemberCount":      navCounts.MemberCount,
249
-		"TeamCount":        navCounts.TeamCount,
250
-		"IsOwner":          isOwner,
267
+		"Title":                           string(org.Slug) + "/" + string(team.Slug),
268
+		"CSRFToken":                       middleware.CSRFTokenForRequest(r),
269
+		"Org":                             org,
270
+		"AvatarURL":                       "/avatars/" + url.PathEscape(string(org.Slug)),
271
+		"ActiveOrgNav":                    "teams",
272
+		"Team":                            team,
273
+		"TeamDisplayName":                 teamDisplayName(team),
274
+		"TeamPath":                        h.teamPath(org, team),
275
+		"TeamPrivacy":                     string(team.Privacy),
276
+		"TeamIsSecret":                    team.Privacy == orgsdb.TeamPrivacySecret,
277
+		"ChildTeams":                      childItems,
278
+		"Members":                         members,
279
+		"MemberCandidates":                memberCandidates,
280
+		"Repos":                           repos,
281
+		"RepoCandidates":                  repoCandidates,
282
+		"RepoCount":                       navCounts.RepoCount,
283
+		"MemberCount":                     navCounts.MemberCount,
284
+		"TeamCount":                       navCounts.TeamCount,
285
+		"IsOwner":                         isOwner,
286
+		"CanExpandTeam":                   canExpandTeam,
287
+		"SecretTeamWritesDisabledMessage": secretTeamWritesDisabledMessage,
288
+		"Notice":                          teamsNoticeMessage(r.URL.Query().Get("notice")),
251289
 	}); err != nil {
252290
 		h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/team_view", "error", err)
253291
 	}
@@ -286,6 +324,16 @@ func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) {
286324
 	case "remove":
287325
 		_ = orgs.RemoveTeamMember(r.Context(), h.deps(), team.ID, uid)
288326
 	default:
327
+		noticeCode, err := h.secretTeamWriteNotice(r.Context(), org.ID, team)
328
+		if err != nil {
329
+			h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", err)
330
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
331
+			return
332
+		}
333
+		if noticeCode != "" {
334
+			http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
335
+			return
336
+		}
289337
 		role := r.PostFormValue("role")
290338
 		_ = orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role)
291339
 	}
@@ -323,6 +371,16 @@ func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) {
323371
 	if r.PostFormValue("action") == "remove" {
324372
 		_ = orgs.RevokeTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID)
325373
 	} else {
374
+		noticeCode, err := h.secretTeamWriteNotice(r.Context(), org.ID, team)
375
+		if err != nil {
376
+			h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", err)
377
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
378
+			return
379
+		}
380
+		if noticeCode != "" {
381
+			http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
382
+			return
383
+		}
326384
 		_ = orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
327385
 			r.PostFormValue("role"))
328386
 	}
internal/web/handlers/orgs/teams_test.gomodified
@@ -13,10 +13,12 @@ import (
1313
 	"strings"
1414
 	"testing"
1515
 	"testing/fstest"
16
+	"time"
1617
 
1718
 	"github.com/go-chi/chi/v5"
1819
 	"github.com/jackc/pgx/v5/pgxpool"
1920
 
21
+	"github.com/tenseleyFlow/shithub/internal/billing"
2022
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
2123
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
2224
 	orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
@@ -117,6 +119,93 @@ func TestTeamCreateBlocksSecretTeamsWithoutEntitlement(t *testing.T) {
117119
 	}
118120
 }
119121
 
122
+func TestSecretTeamAddMemberRequiresEntitlementButRemoveAllowed(t *testing.T) {
123
+	t.Parallel()
124
+	ctx := context.Background()
125
+	pool := dbtest.NewTestDB(t)
126
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
127
+	memberID := insertOrgAvatarUser(t, pool, "member")
128
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
129
+	if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'member')`, orgID, memberID); err != nil {
130
+		t.Fatalf("insert org member: %v", err)
131
+	}
132
+	teamID := insertTeamForTest(t, pool, orgID, "security", "Security", "secret")
133
+
134
+	form := url.Values{"user_id": {strconv.FormatInt(memberID, 10)}, "role": {"member"}}
135
+	body, status, location := performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/security/members", form)
136
+	if status != http.StatusSeeOther {
137
+		t.Fatalf("free add status=%d body=%s", status, body)
138
+	}
139
+	if location != "/acme/teams/security?notice=secret-teams-upgrade" {
140
+		t.Fatalf("free add redirect=%q", location)
141
+	}
142
+	assertTeamMemberCount(t, pool, teamID, 0)
143
+
144
+	if _, err := pool.Exec(ctx, `INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, 'member')`, teamID, memberID); err != nil {
145
+		t.Fatalf("seed team member: %v", err)
146
+	}
147
+	assertTeamMemberCount(t, pool, teamID, 1)
148
+
149
+	remove := url.Values{
150
+		"user_id": {strconv.FormatInt(memberID, 10)},
151
+		"action":  {"remove"},
152
+	}
153
+	body, status, _ = performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/security/members", remove)
154
+	if status != http.StatusSeeOther {
155
+		t.Fatalf("remove status=%d body=%s", status, body)
156
+	}
157
+	assertTeamMemberCount(t, pool, teamID, 0)
158
+
159
+	activateTeamPlanForTest(t, pool, orgID)
160
+	body, status, _ = performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/security/members", form)
161
+	if status != http.StatusSeeOther {
162
+		t.Fatalf("team add status=%d body=%s", status, body)
163
+	}
164
+	assertTeamMemberCount(t, pool, teamID, 1)
165
+}
166
+
167
+func TestSecretTeamRepoGrantRequiresEntitlementButRevokeAllowed(t *testing.T) {
168
+	t.Parallel()
169
+	ctx := context.Background()
170
+	pool := dbtest.NewTestDB(t)
171
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
172
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
173
+	teamID := insertTeamForTest(t, pool, orgID, "security", "Security", "secret")
174
+	repoID := insertTeamRepoForTest(t, pool, orgID, "private-repo")
175
+
176
+	form := url.Values{"repo_id": {strconv.FormatInt(repoID, 10)}, "role": {"write"}}
177
+	body, status, location := performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/security/repos", form)
178
+	if status != http.StatusSeeOther {
179
+		t.Fatalf("free grant status=%d body=%s", status, body)
180
+	}
181
+	if location != "/acme/teams/security?notice=secret-teams-upgrade" {
182
+		t.Fatalf("free grant redirect=%q", location)
183
+	}
184
+	assertTeamRepoGrantCount(t, pool, teamID, 0)
185
+
186
+	if _, err := pool.Exec(ctx, `INSERT INTO team_repo_access (team_id, repo_id, role) VALUES ($1, $2, 'write')`, teamID, repoID); err != nil {
187
+		t.Fatalf("seed team repo access: %v", err)
188
+	}
189
+	assertTeamRepoGrantCount(t, pool, teamID, 1)
190
+
191
+	remove := url.Values{
192
+		"repo_id": {strconv.FormatInt(repoID, 10)},
193
+		"action":  {"remove"},
194
+	}
195
+	body, status, _ = performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/security/repos", remove)
196
+	if status != http.StatusSeeOther {
197
+		t.Fatalf("revoke status=%d body=%s", status, body)
198
+	}
199
+	assertTeamRepoGrantCount(t, pool, teamID, 0)
200
+
201
+	activateTeamPlanForTest(t, pool, orgID)
202
+	body, status, _ = performTeamsRequest(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, http.MethodPost, "/acme/teams/security/repos", form)
203
+	if status != http.StatusSeeOther {
204
+		t.Fatalf("team grant status=%d body=%s", status, body)
205
+	}
206
+	assertTeamRepoGrantCount(t, pool, teamID, 1)
207
+}
208
+
120209
 func performTeamsListRequest(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, target string) (string, int, string) {
121210
 	return performTeamsRequest(t, pool, viewer, http.MethodGet, target, nil)
122211
 }
@@ -178,3 +267,57 @@ func insertTeamForTest(t *testing.T, db orgsdb.DBTX, orgID int64, slug, displayN
178267
 	}
179268
 	return id
180269
 }
270
+
271
+func insertTeamRepoForTest(t *testing.T, db orgsdb.DBTX, orgID int64, name string) int64 {
272
+	t.Helper()
273
+	var id int64
274
+	if err := db.QueryRow(context.Background(),
275
+		`INSERT INTO repos (owner_org_id, name, visibility, default_branch)
276
+		 VALUES ($1, $2, 'private', 'trunk')
277
+		 RETURNING id`,
278
+		orgID, name,
279
+	).Scan(&id); err != nil {
280
+		t.Fatalf("insert repo: %v", err)
281
+	}
282
+	return id
283
+}
284
+
285
+func activateTeamPlanForTest(t *testing.T, pool *pgxpool.Pool, orgID int64) {
286
+	t.Helper()
287
+	now := time.Now().UTC().Truncate(time.Second)
288
+	_, err := billing.ApplySubscriptionSnapshot(context.Background(), billing.Deps{Pool: pool}, billing.SubscriptionSnapshot{
289
+		OrgID:                    orgID,
290
+		Plan:                     billing.PlanTeam,
291
+		Status:                   billing.SubscriptionStatusActive,
292
+		StripeSubscriptionID:     "sub_teams_" + strconv.FormatInt(orgID, 10),
293
+		StripeSubscriptionItemID: "si_teams_" + strconv.FormatInt(orgID, 10),
294
+		CurrentPeriodStart:       now,
295
+		CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
296
+		LastWebhookEventID:       "evt_teams_" + strconv.FormatInt(orgID, 10),
297
+	})
298
+	if err != nil {
299
+		t.Fatalf("activate team plan: %v", err)
300
+	}
301
+}
302
+
303
+func assertTeamMemberCount(t *testing.T, db orgsdb.DBTX, teamID int64, want int) {
304
+	t.Helper()
305
+	var count int
306
+	if err := db.QueryRow(context.Background(), `SELECT count(*) FROM team_members WHERE team_id = $1`, teamID).Scan(&count); err != nil {
307
+		t.Fatalf("count team members: %v", err)
308
+	}
309
+	if count != want {
310
+		t.Fatalf("team member count=%d, want %d", count, want)
311
+	}
312
+}
313
+
314
+func assertTeamRepoGrantCount(t *testing.T, db orgsdb.DBTX, teamID int64, want int) {
315
+	t.Helper()
316
+	var count int
317
+	if err := db.QueryRow(context.Background(), `SELECT count(*) FROM team_repo_access WHERE team_id = $1`, teamID).Scan(&count); err != nil {
318
+		t.Fatalf("count team repo grants: %v", err)
319
+	}
320
+	if count != want {
321
+		t.Fatalf("team repo grant count=%d, want %d", count, want)
322
+	}
323
+}
internal/web/handlers/repo/settings_branches_test.gomodified
@@ -7,6 +7,7 @@ import (
77
 	"net/http"
88
 	"net/http/httptest"
99
 	"net/url"
10
+	"strconv"
1011
 	"testing"
1112
 	"time"
1213
 
@@ -131,6 +132,51 @@ func TestSettingsBranchesAllowsPaidPrivateOrgRepoAdvancedSettings(t *testing.T)
131132
 	assertBranchProtectionRule(t, f, repo.ID, 2, []string{"ci", "lint"}, true, true)
132133
 }
133134
 
135
+func TestSettingsBranchesAllowsDowngradedOrgToRemoveAdvancedSettings(t *testing.T) {
136
+	t.Parallel()
137
+	f := newRepoFixture(t)
138
+	orgID := f.insertOwnedOrg(t, "acme")
139
+	repo := f.insertOrgRepo(t, orgID, "private-org-repo", reposdb.RepoVisibilityPrivate)
140
+	ruleID, err := f.handlers.rq.UpsertBranchProtectionRule(context.Background(), f.pool, reposdb.UpsertBranchProtectionRuleParams{
141
+		RepoID:          repo.ID,
142
+		Pattern:         "trunk",
143
+		PreventDeletion: true,
144
+	})
145
+	if err != nil {
146
+		t.Fatalf("seed branch rule: %v", err)
147
+	}
148
+	if err := f.handlers.rq.UpdateBranchProtectionReviewSettings(context.Background(), f.pool, reposdb.UpdateBranchProtectionReviewSettingsParams{
149
+		ID:                        ruleID,
150
+		RequiredReviewCount:       2,
151
+		DismissStaleReviewsOnPush: true,
152
+	}); err != nil {
153
+		t.Fatalf("seed review settings: %v", err)
154
+	}
155
+	if err := f.handlers.rq.UpdateBranchProtectionCheckSettings(context.Background(), f.pool, reposdb.UpdateBranchProtectionCheckSettingsParams{
156
+		ID:                             ruleID,
157
+		StatusChecksRequired:           []string{"ci"},
158
+		DismissStaleStatusChecksOnPush: true,
159
+	}); err != nil {
160
+		t.Fatalf("seed check settings: %v", err)
161
+	}
162
+
163
+	mux := f.branchesSettingsMux(f.owner.ID, f.owner.Username)
164
+	resp := httptest.NewRecorder()
165
+	req := newFormRequest(http.MethodPost, "/acme/private-org-repo/settings/branches", url.Values{
166
+		"id":      {strconv.FormatInt(ruleID, 10)},
167
+		"pattern": {"trunk"},
168
+	})
169
+	mux.ServeHTTP(resp, req)
170
+
171
+	if resp.Code != http.StatusSeeOther {
172
+		t.Fatalf("POST status=%d body=%s", resp.Code, resp.Body.String())
173
+	}
174
+	if got := resp.Header().Get("Location"); got != "/acme/private-org-repo/settings/branches?notice=saved" {
175
+		t.Fatalf("redirect location=%q", got)
176
+	}
177
+	assertBranchProtectionRule(t, f, repo.ID, 0, nil, false, false)
178
+}
179
+
134180
 func (f *repoFixture) branchesSettingsMux(userID int64, username string) http.Handler {
135181
 	mux := chi.NewRouter()
136182
 	mux.Use(func(next http.Handler) http.Handler {
internal/web/templates/orgs/team_view.htmlmodified
@@ -15,6 +15,8 @@
1515
       <span class="shithub-pill{{ if .TeamIsSecret }} shithub-pill-private{{ end }}">{{ if .TeamIsSecret }}{{ octicon "lock" }} Secret{{ else }}{{ octicon "eye" }} Visible{{ end }}</span>
1616
     </div>
1717
     {{ if .Team.Description }}<p class="shithub-org-team-description">{{ .Team.Description }}</p>{{ else }}<p class="shithub-org-team-description shithub-muted">No description provided.</p>{{ end }}
18
+    {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
19
+    {{ with .SecretTeamWritesDisabledMessage }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
1820
   </header>
1921
 
2022
   <div class="shithub-org-team-view-layout">
@@ -125,6 +127,7 @@
125127
 
126128
     <aside class="shithub-org-team-manage" aria-label="Team management">
127129
       {{ if .IsOwner }}
130
+      {{ if .CanExpandTeam }}
128131
       <section class="shithub-org-team-manage-box">
129132
         <h2>Add member</h2>
130133
         {{ if .MemberCandidates }}
@@ -176,6 +179,7 @@
176179
         {{ end }}
177180
       </section>
178181
       {{ end }}
182
+      {{ end }}
179183
 
180184
       <section class="shithub-org-team-manage-box">
181185
         <h2>Team visibility</h2>