Add a regression test for the team/admin/self authorization pattern

Part of the same security-hardening pass as the last four commits.
terdut-server's authz is already solid -- centralized predicates
(requireTeamMember, requireTeamOwner, requireSelfOrAdmin, AdminOnly)
rather than ad hoc per-handler checks, confirmed by spot-checking several
handlers while writing this. But it is enforced by convention, not the
type system: a future handler that forgets its guard would compile and
read fine on review, exactly like one that remembers it.

authz_scope_test.go builds two teams and, for every team-scoped route
(members, OIDC groups, invites, escalation, dead man's switches,
integrations, schedule, plus every /api/incidents/{id}/... route, scoped
by the incident's own team_id through incidentIDParam's single
chokepoint), calls it as one team's owner against the other team's
resources and asserts 404 -- requireTeamMember and requireTeamOwner both
answer a non-member that way. Separate tests cover AdminOnly's routes
(403 for a non-admin) and requireSelfOrAdmin's (403 for a non-admin
acting on someone else's account).

Verified the test actually catches a regression, not just that it
passes today: temporarily removed handleListTeamMembers' requireTeamMember
call, confirmed exactly that one subtest failed and nothing else did,
then put it back.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Niklas Ye
2026-10-07 22:21:46 +02:00
parent 92959cac38
commit a2ca9c25d0
+181
View File
@@ -0,0 +1,181 @@
package api_test
import (
"net/http"
"testing"
)
// This file is the regression test for the pattern documented throughout
// middleware.go: every team-scoped handler calls requireTeamMember or
// requireTeamOwner before touching data, every self-or-admin handler calls
// requireSelfOrAdmin, and every admin-only route sits behind AdminOnly. That
// pattern is enforced by convention, not by the type system — a new handler
// that forgets the call would compile and pass review on a quick read just
// as easily as one that remembers it. These tests exercise every route that
// carries one of those guards as a caller who should be refused, so a future
// handler missing its guard fails CI instead of becoming a silent IDOR.
// TestAuthzScope_TeamScopedRoutesRefuseANonMember builds two teams and, for
// every team-scoped route, calls it as team A's owner against team B's
// resources. requireTeamMember and requireTeamOwner both answer a non-member
// with 404 (team.go's own reasoning: whether a team exists is itself
// something only its members should learn), so every one of these must come
// back 404 regardless of which of the two guards its handler uses.
func TestAuthzScope_TeamScopedRoutesRefuseANonMember(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "authz-a")
b := newTeam(t, s, "authz-b")
// An incident in B, to cover the ID-based routes under /api/incidents —
// scoped by the incident's own team_id rather than a {teamID} path
// segment, but through the same single chokepoint (incidentIDParam).
postToIntegration(t, s, b.key, "fp-authz-scope", "AuthzScopeAlert")
var incidents []struct {
ID int64 `json:"id"`
}
decode(t, b.call(http.MethodGet, "/api/incidents", nil), &incidents)
if len(incidents) == 0 {
t.Fatal("setup: no incident in team B to test against")
}
incidentPath := "/api/incidents/" + id64(incidents[0].ID)
bPath := "/api/teams/" + id64(b.id)
tests := []struct {
method, path string
}{
// Team membership/ownership itself.
{http.MethodPut, bPath},
{http.MethodDelete, bPath},
{http.MethodGet, bPath + "/members"},
{http.MethodPost, bPath + "/members"},
{http.MethodDelete, bPath + "/members/1"},
// OIDC group binding.
{http.MethodGet, bPath + "/oidc-groups"},
{http.MethodPut, bPath + "/oidc-groups"},
// Invites.
{http.MethodGet, bPath + "/invites"},
{http.MethodPost, bPath + "/invites"},
{http.MethodDelete, bPath + "/invites/1"},
// Escalation.
{http.MethodGet, bPath + "/escalation"},
{http.MethodPut, bPath + "/escalation"},
// Dead man's switches.
{http.MethodGet, bPath + "/deadman/switches"},
{http.MethodPost, bPath + "/deadman/switches"},
{http.MethodPut, bPath + "/deadman/switches/1"},
{http.MethodDelete, bPath + "/deadman/switches/1"},
// Integrations.
{http.MethodGet, bPath + "/integrations"},
{http.MethodPost, bPath + "/integrations"},
{http.MethodPatch, bPath + "/integrations/1"},
{http.MethodDelete, bPath + "/integrations/1"},
// Schedule.
{http.MethodGet, bPath + "/schedule"},
{http.MethodPost, bPath + "/schedule"},
{http.MethodDelete, bPath + "/schedule/1"},
// Incidents, scoped by the incident's own team rather than a
// {teamID} segment.
{http.MethodGet, incidentPath},
{http.MethodGet, incidentPath + "/alerts"},
{http.MethodGet, incidentPath + "/timeline"},
{http.MethodGet, incidentPath + "/similar"},
{http.MethodPost, incidentPath + "/acknowledge"},
{http.MethodDelete, incidentPath + "/acknowledge"},
{http.MethodPost, incidentPath + "/resolve"},
{http.MethodPost, incidentPath + "/assign"},
{http.MethodPost, incidentPath + "/snooze"},
{http.MethodDelete, incidentPath + "/snooze"},
{http.MethodPost, incidentPath + "/archive"},
{http.MethodDelete, incidentPath + "/archive"},
{http.MethodPost, incidentPath + "/notes"},
{http.MethodDelete, incidentPath + "/notes/1"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := a.call(tc.method, tc.path, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want %d (A is not a member of B)", resp.StatusCode, http.StatusNotFound)
}
})
}
}
// TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin exercises AdminOnly's group
// in router.go directly: a signed-in, non-admin caller gets 403 from every
// route in it, before any handler body runs.
func TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "authz-admin")
tests := []struct {
method, path string
}{
{http.MethodPost, "/api/users"},
{http.MethodDelete, "/api/users/1"},
{http.MethodPut, "/api/users/1/admin"},
{http.MethodPut, "/api/users/1/disabled"},
{http.MethodGet, "/api/admin/teams"},
{http.MethodGet, "/api/admin/teams/" + id64(a.id)},
{http.MethodGet, "/api/admin/settings"},
{http.MethodPut, "/api/admin/settings"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := a.call(tc.method, tc.path, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d (not an admin)", resp.StatusCode, http.StatusForbidden)
}
})
}
}
// TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser exercises
// requireSelfOrAdmin's call sites: a non-admin caller acting on a *different*
// user's account must be refused, the same as AdminOnly's routes, even
// though these sit in the general authenticated group rather than behind
// AdminOnly itself.
func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "authz-self-a")
b := newTeam(t, s, "authz-self-b")
var members []struct {
UserID int64 `json:"user_id"`
}
decode(t, s.req(t, http.MethodGet, "/api/teams/"+id64(b.id)+"/members", nil), &members)
if len(members) == 0 {
t.Fatal("setup: team B has no members")
}
bUserID := id64(members[0].UserID)
tests := []struct {
method, path string
}{
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
{http.MethodPut, "/api/users/" + bUserID + "/password"},
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
{http.MethodDelete, "/api/users/" + bUserID + "/api-keys/1"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := a.call(tc.method, tc.path, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
}
})
}
}