9029d48584
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
135 lines
4.5 KiB
Go
135 lines
4.5 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
|
)
|
|
|
|
const (
|
|
operatorKeyOne = "tdsa_operator-key-number-one-0123456789"
|
|
operatorKeyTwo = "tdsa_operator-key-number-two-0123456789"
|
|
)
|
|
|
|
func TestSeedOperatorKey_AuthenticatesAsInstanceAccount(t *testing.T) {
|
|
s := newTS(t)
|
|
if err := api.SeedOperatorKey(context.Background(), s.db, operatorKeyOne); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp := s.reqAs(t, operatorKeyOne, http.MethodPost, "/api/teams", map[string]string{"name": "seeded"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("seeded key should create a team, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSeedOperatorKey_RotationReplacesAndIsIdempotent(t *testing.T) {
|
|
s := newTS(t)
|
|
ctx := context.Background()
|
|
for _, key := range []string{operatorKeyOne, operatorKeyOne, operatorKeyTwo} {
|
|
if err := api.SeedOperatorKey(ctx, s.db, key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
old := s.reqAs(t, operatorKeyOne, http.MethodGet, "/api/teams", nil)
|
|
old.Body.Close()
|
|
if old.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("rotated-out key should be refused, got %d", old.StatusCode)
|
|
}
|
|
cur := s.reqAs(t, operatorKeyTwo, http.MethodGet, "/api/teams", nil)
|
|
cur.Body.Close()
|
|
if cur.StatusCode != http.StatusOK {
|
|
t.Errorf("current key should work, got %d", cur.StatusCode)
|
|
}
|
|
|
|
var accounts, keys int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM service_accounts WHERE name = 'terdut-operator'").Scan(&accounts)
|
|
s.db.QueryRow("SELECT COUNT(*) FROM service_account_keys").Scan(&keys)
|
|
if accounts != 1 || keys != 1 {
|
|
t.Errorf("expected one account and one key, got %d and %d", accounts, keys)
|
|
}
|
|
}
|
|
|
|
func TestSeedOperatorKey_EmptyKeyDoesNothing(t *testing.T) {
|
|
s := newTS(t)
|
|
if err := api.SeedOperatorKey(context.Background(), s.db, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var n int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM service_accounts").Scan(&n)
|
|
if n != 0 {
|
|
t.Errorf("expected no service account, got %d", n)
|
|
}
|
|
}
|
|
|
|
// The instance account configures a team it did not create, which is what lets
|
|
// the operator hold one credential instead of one per team, yet it is not a
|
|
// member and so reads none of the team's incidents.
|
|
func TestInstanceAccount_ActsAsOwnerOfAnyTeamButIsNoMember(t *testing.T) {
|
|
s := newTS(t)
|
|
other := newTeam(t, s, "other")
|
|
if err := api.SeedOperatorKey(context.Background(), s.db, operatorKeyOne); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rename := s.reqAs(t, operatorKeyOne, http.MethodPut, "/api/teams/"+id64(other.id), map[string]string{"name": "renamed"})
|
|
rename.Body.Close()
|
|
if rename.StatusCode >= 300 {
|
|
t.Errorf("instance account should rename any team, got %d", rename.StatusCode)
|
|
}
|
|
|
|
// Not a member: the team's queue is not visible to it.
|
|
var queue []map[string]any
|
|
decode(t, s.reqAs(t, operatorKeyOne, http.MethodGet, "/api/incidents", nil), &queue)
|
|
if len(queue) != 0 {
|
|
t.Errorf("instance account should see no incidents, got %v", queue)
|
|
}
|
|
}
|
|
|
|
// external_id lets automation find its own team again after a crash, without
|
|
// trusting a display name.
|
|
func TestCreateTeam_ExternalIDIsIdempotentAndInstanceOnly(t *testing.T) {
|
|
s := newTS(t)
|
|
if err := api.SeedOperatorKey(context.Background(), s.db, operatorKeyOne); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
create := func(name string) (int, map[string]any) {
|
|
resp := s.reqAs(t, operatorKeyOne, http.MethodPost, "/api/teams",
|
|
map[string]string{"name": name, "external_id": "ns/platform"})
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
resp.Body.Close()
|
|
return resp.StatusCode, out
|
|
}
|
|
|
|
code, first := create("Platform")
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("first create: %d", code)
|
|
}
|
|
// Same identity, even under a new display name: the same team comes back.
|
|
code, again := create("Platform renamed")
|
|
if code != http.StatusOK || again["id"] != first["id"] {
|
|
t.Errorf("repeat with the same external_id: want 200 and team %v, got %d %v", first["id"], code, again)
|
|
}
|
|
|
|
// A different identity cannot take the name.
|
|
resp := s.reqAs(t, operatorKeyOne, http.MethodPost, "/api/teams",
|
|
map[string]string{"name": "Platform", "external_id": "other/platform"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Errorf("taken name under another external_id: want 409, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// A person cannot set one.
|
|
resp = s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "Mine", "external_id": "x/y"})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("a user setting external_id: want 403, got %d", resp.StatusCode)
|
|
}
|
|
}
|