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.
This commit is contained in:
+36
-4
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user