From bc9f793f1f8be714c146184826ace615385721be Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 1 Oct 2026 10:49:11 +0200 Subject: [PATCH] Add GET /api/teams?name= (TEAM-LOOKUP.md) Resolves the gap TEAM-LOOKUP.md raised: an instance-scoped service account had no way to recover a team's id after a 409 on POST /api/teams, unlike the already-solved equivalent for service accounts themselves (GET /api/service-accounts?name=). Same route, extended the same way handleListServiceAccounts already branches on ?name=: unset behaves exactly as before (the caller's own teams via team_members); set looks up one team by exact name, open to any authenticated caller -- not gated by isInstanceServiceAccount or AdminOnly, since what it discloses (a name is taken, nothing about who's in it) is the same low sensitivity that lookup already accepts for service-account names. Tests cover the exact motivating scenario (create, 409 on a retry, recover the id via ?name=), the empty-array-not-an-error case, that no role/source is reported for a non-member match, and that a caller who isn't a member of the matched team still gets it. --- internal/api/teams.go | 40 ++++++++++++++++++--- internal/api/teams_test.go | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/internal/api/teams.go b/internal/api/teams.go index e2c9b45..e2b17c7 100644 --- a/internal/api/teams.go +++ b/internal/api/teams.go @@ -13,12 +13,19 @@ import ( "github.com/go-chi/chi/v5" ) -// handleListTeams lists the caller's own teams, each with their role in it. An -// administrator listing every team goes through the admin endpoint instead: -// this one answers "what am I part of", which is what the UI's team filter and -// the combined queue are built from. +// handleListTeams lists the caller's own teams, each with their role in it, +// or — with ?name= — looks up one team by exact name regardless of caller +// identity (TEAM-LOOKUP.md). An administrator listing every team goes +// through the admin endpoint instead: the no-name case here answers "what am +// I part of", which is what the UI's team filter and the combined queue are +// built from. func handleListTeams(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + handleListTeamsByName(db, w, r, name) + return + } + caller, _ := userFromContext(r.Context()) rows, err := db.QueryContext(r.Context(), ` SELECT t.id, t.name, t.created_at, m.role, m.source @@ -51,6 +58,31 @@ func handleListTeams(db *sql.DB) http.HandlerFunc { } } +// handleListTeamsByName answers "is there a team named exactly this", open to +// any authenticated caller including a service account (TEAM-LOOKUP.md) — +// mirrors handleListServiceAccounts' own ?name= lookup: a one-or-zero-length +// array, never an error on no match, and no caller-identity filtering at +// all, since what it discloses (a name is taken, nothing about who's in it +// or any of its data) is the same low sensitivity that lookup already +// accepts for service-account names. +func handleListTeamsByName(db *sql.DB, w http.ResponseWriter, r *http.Request, name string) { + var t models.Team + var created int64 + err := db.QueryRowContext(r.Context(), + "SELECT id, name, created_at FROM teams WHERE name = $1", name, + ).Scan(&t.ID, &t.Name, &created) + if errors.Is(err, sql.ErrNoRows) { + respond(w, http.StatusOK, []models.Team{}) + return + } + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + t.CreatedAt = time.Unix(created, 0).UTC() + respond(w, http.StatusOK, []models.Team{t}) +} + // handleUserTeams lists one user's teams, for the admin page's per-user view: // "what is this person in", which /api/teams cannot answer because it is always // about the caller. diff --git a/internal/api/teams_test.go b/internal/api/teams_test.go index d8d317a..8cebe1a 100644 --- a/internal/api/teams_test.go +++ b/internal/api/teams_test.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "testing" + + "git.ryuvia.com/niklas/terdut-server/internal/models" ) // The whole point of #4: two teams sharing one server must not see each other's @@ -454,3 +456,75 @@ func TestTeams_OutsiderSeesNothing(t *testing.T) { t.Errorf("blue's team list: %v", teams) } } + +// --------------------------------------------------------------------------- +// GET /api/teams?name= (TEAM-LOOKUP.md) +// --------------------------------------------------------------------------- + +func TestListTeamsByName_FindsExactMatch(t *testing.T) { + s := newTS(t) + instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0) + teamID := createTeamAs(t, s, instanceKey, "platform") + + teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=platform", nil)) + if len(teams) != 1 { + t.Fatalf("expected exactly one match for ?name=platform, got %d: %v", len(teams), teams) + } + if int64(teams[0]["id"].(float64)) != teamID { + t.Errorf("id = %v, want %d", teams[0]["id"], teamID) + } + // No membership, so no role to report (models.Team's own doc comment: + // "empty when nobody in particular is asking"). + if _, has := teams[0]["role"]; has { + t.Errorf("expected no role on a name-lookup match, got %v", teams[0]["role"]) + } +} + +func TestListTeamsByName_NoMatchIsAnEmptyArrayNotAnError(t *testing.T) { + s := newTS(t) + instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0) + + resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=does-not-exist", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 on no match, got %d", resp.StatusCode) + } + teams := list(t, resp) + if len(teams) != 0 { + t.Errorf("expected an empty array, got %v", teams) + } +} + +// The actual motivating scenario (TEAM-LOOKUP.md): a service account that +// already created a team, interrupted before it could remember the id, +// recovers it via ?name= on the same name its own POST 409s on. +func TestListTeamsByName_RecoversAfterCreateConflict(t *testing.T) { + s := newTS(t) + instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0) + original := createTeamAs(t, s, instanceKey, "recovered") + + conflict := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "recovered"}) + if conflict.StatusCode != http.StatusConflict { + t.Fatalf("expected 409 recreating the same name, got %d", conflict.StatusCode) + } + conflict.Body.Close() + + teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=recovered", nil)) + if len(teams) != 1 || int64(teams[0]["id"].(float64)) != original { + t.Fatalf("expected to recover the original team %d via ?name=, got %v", original, teams) + } +} + +// Not gated by isInstanceServiceAccount or AdminOnly (TEAM-LOOKUP.md): any +// authenticated caller may ask whether a name is taken, the same low +// sensitivity GET /api/service-accounts?name= already accepts. +func TestListTeamsByName_OpenToAnyAuthenticatedCaller(t *testing.T) { + s := newTS(t) + red := newTeam(t, s, "red") + _ = createTeamAs(t, s, s.key, "blue-target") + + // red's own member, not a member of "blue-target", still gets a match. + teams := list(t, red.call(http.MethodGet, "/api/teams?name=blue-target", nil)) + if len(teams) != 1 || teams[0]["name"] != "blue-target" { + t.Errorf("expected a non-member caller to still find the team by name, got %v", teams) + } +}