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"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleListTeams lists the caller's own teams, each with their role in it. An
|
// handleListTeams lists the caller's own teams, each with their role in it,
|
||||||
// administrator listing every team goes through the admin endpoint instead:
|
// or — with ?name= — looks up one team by exact name regardless of caller
|
||||||
// this one answers "what am I part of", which is what the UI's team filter and
|
// identity (TEAM-LOOKUP.md). An administrator listing every team goes
|
||||||
// the combined queue are built from.
|
// 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 {
|
func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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())
|
caller, _ := userFromContext(r.Context())
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT t.id, t.name, t.created_at, m.role, m.source
|
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:
|
// 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
|
// "what is this person in", which /api/teams cannot answer because it is always
|
||||||
// about the caller.
|
// about the caller.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"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
|
// 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)
|
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