Covers webhook Secret generation/ownership (DESIGN.md §4.5, §7), the
WebhookSecretLost fail-closed condition, and the kind-change
delete-and-recreate rotation path.
Idempotent-create here is deliberately neither adopt-on-409
(Team/service-account) nor list-and-match-by-name (TerdutDeadmanSwitch):
terdut-server shows the webhook key exactly once, at creation, and never
again, so no server-side lookup could ever recover it after a crash.
Instead the generated webhook Secret itself -- written immediately after
the POST, before status is ever touched -- is this CR's only durable
record that a create already succeeded; found with status.integrationID
still unset on a later reconcile, it's read back directly rather than
POSTing a second, orphaned integration. Found missing with
status.integrationID *set* instead, that's the already-designed
WebhookSecretLost case: fail closed, not self-healed, since the key is
genuinely gone and recreating it would rotate a live webhook URL with no
spec change to explain why.
Renaming (PATCH) never touches the key, so it's applied unconditionally
every reconcile, same as the escalation policy's whole-policy PUT. A
spec.kind change is the one case with no in-place update verb at all:
DELETE the old integration, delete the stale webhook Secret, then run the
same create path fresh -- fires a Warning event since this breaks whatever
still sends to the old URL.
Also: fakeTerdutServer grows POST/PATCH/DELETE .../integrations routes
behind a new handleIntegrationSubPath, split out of handleTeamSubPath to
stay under gocyclo's threshold; three goconst-flagged test literals
("does-not-exist", "unready") and one unparam-flagged test helper
parameter (bootstrapReadyTerdutServer's always-"default" namespace) get
shared/removed now that a fourth same-shaped caller made the repetition
concrete enough for the linter to flag.
DESIGN.md §13 gains one honest gap found while grounding this stage, not
introduced by it: no child CRD specially detects a mid-life teamRef
change; all three always resolve spec.teamRef fresh and trust the
already-stored server-side id remains valid there.
make fmt lint test build all clean; internal/controller envtest coverage
holds at 71.6%.
This commit is contained in:
@@ -72,6 +72,13 @@ type fakeTerdutServer struct {
|
||||
nextSwitchID int64
|
||||
switches map[int64]map[int64]tdclient.DeadmanSwitch // teamID -> switchID -> switch
|
||||
switchDelete map[int64]bool // switchID -> true once DELETEd, for 404-on-redelete
|
||||
|
||||
// integrations/nextIntegrationID/integrationDelete back the alert
|
||||
// source endpoints -- no unique-name constraint server-side either
|
||||
// (DESIGN.md §4.5), same shape as switches, keyed by id.
|
||||
nextIntegrationID int64
|
||||
integrations map[int64]map[int64]tdclient.Integration // teamID -> integrationID -> integration
|
||||
integrationDelete map[int64]bool // integrationID -> true once DELETEd, for 404-on-redelete
|
||||
}
|
||||
|
||||
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
||||
@@ -86,6 +93,9 @@ func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
||||
escalation: map[int64]tdclient.SetEscalationRequest{},
|
||||
switches: map[int64]map[int64]tdclient.DeadmanSwitch{},
|
||||
switchDelete: map[int64]bool{},
|
||||
|
||||
integrations: map[int64]map[int64]tdclient.Integration{},
|
||||
integrationDelete: map[int64]bool{},
|
||||
}
|
||||
return f, httptest.NewServer(f)
|
||||
}
|
||||
@@ -314,6 +324,76 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
|
||||
f.switchDelete[switchID] = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"):
|
||||
f.handleIntegrationSubPath(w, r, id, rest)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// handleIntegrationSubPath answers POST /api/teams/{id}/integrations,
|
||||
// PATCH .../integrations/{integrationID} and DELETE .../integrations/{integrationID}
|
||||
// -- split out of handleTeamSubPath so that switch's own cyclomatic
|
||||
// complexity stays under golangci-lint's gocyclo threshold.
|
||||
func (f *fakeTerdutServer) handleIntegrationSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
||||
switch {
|
||||
case rest == "/integrations" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
f.nextIntegrationID++
|
||||
integID := f.nextIntegrationID
|
||||
integ := tdclient.Integration{
|
||||
ID: integID, TeamID: id, Kind: req.Kind, Name: req.Name,
|
||||
// Key/URL are only ever in *this* response -- never again,
|
||||
// matching terdut-server's own one-time-show semantics
|
||||
// (DESIGN.md §4.5) -- so what's stored for later GET/PATCH
|
||||
// calls in this fake deliberately omits them too.
|
||||
Key: fmt.Sprintf("webhook-key-%d", integID),
|
||||
URL: fmt.Sprintf("https://terdut.example.invalid/api/integrations/webhook-key-%d/%s", integID, req.Kind),
|
||||
}
|
||||
if f.integrations[id] == nil {
|
||||
f.integrations[id] = map[int64]tdclient.Integration{}
|
||||
}
|
||||
f.integrations[id][integID] = tdclient.Integration{ID: integID, TeamID: id, Kind: req.Kind, Name: req.Name}
|
||||
writeJSON(w, http.StatusCreated, integ)
|
||||
|
||||
case strings.HasPrefix(rest, "/integrations/") && r.Method == http.MethodPatch:
|
||||
integID, ok := parseTrailingID(rest, "/integrations/")
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
existing, exists := f.integrations[id][integID]
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
existing.Name = req.Name
|
||||
f.integrations[id][integID] = existing
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case strings.HasPrefix(rest, "/integrations/") && r.Method == http.MethodDelete:
|
||||
integID, ok := parseTrailingID(rest, "/integrations/")
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, exists := f.integrations[id][integID]; !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(f.integrations[id], integID)
|
||||
f.integrationDelete[integID] = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user