package controller import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "strings" "sync" "git.ryuvia.com/niklas/terdut-operator/internal/tdclient" ) // errJSONKey is the key every error body the fake writes uses, and // deadmanSwitchesPath the literal shared by its dead man's switch routes -- // goconst would otherwise flag the repeats. const ( errJSONKey = "error" deadmanSwitchesPath = "/deadman/switches" ) // fakeTerdutServer reproduces the parts of terdut-server's API the controllers // call, with the same status codes and idempotency rules (POST /api/teams // keyed by external_id, unique team names, unique switch names per team), so a // controller test exercises the real contract and not a canned reply. type fakeTerdutServer struct { mu sync.Mutex // expectKey, when set, makes every request without that bearer token a 401. expectKey string nextTeamID int64 teams map[string]int64 // name -> id teamNames map[int64]string // id -> current name teamExt map[string]int64 // external_id -> id teamOIDC map[int64][2]string teamDelete map[int64]bool // id -> true once DELETEd // unknownUsers are usernames the escalation PUT answers 400 "unknown user" for. unknownUsers map[string]bool escalation map[int64]tdclient.SetEscalationRequest nextSwitchID int64 switches map[int64]map[int64]tdclient.DeadmanSwitch // teamID -> switchID -> switch switchDelete map[int64]bool nextIntegrationID int64 integrations map[int64]map[int64]tdclient.Integration integrationDelete map[int64]bool } func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) { f := &fakeTerdutServer{ teams: map[string]int64{}, teamNames: map[int64]string{}, teamExt: map[string]int64{}, teamOIDC: map[int64][2]string{}, teamDelete: map[int64]bool{}, unknownUsers: map[string]bool{}, 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) } func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if f.expectKey != "" && r.Header.Get("Authorization") != "Bearer "+f.expectKey { writeJSON(w, http.StatusUnauthorized, map[string]string{errJSONKey: "invalid or expired API key"}) return } switch { case r.URL.Path == "/api/version": writeJSON(w, http.StatusOK, map[string]string{"version": "test"}) case r.URL.Path == "/api/teams" && r.Method == http.MethodPost: var req struct { Name string `json:"name"` ExternalID string `json:"external_id"` } _ = json.NewDecoder(r.Body).Decode(&req) if id, ok := f.teamExt[req.ExternalID]; ok && req.ExternalID != "" { writeJSON(w, http.StatusOK, tdclient.Team{ID: id, Name: f.teamNames[id]}) return } if _, exists := f.teams[req.Name]; exists { writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"}) return } f.nextTeamID++ id := f.nextTeamID f.teams[req.Name] = id f.teamNames[id] = req.Name if req.ExternalID != "" { f.teamExt[req.ExternalID] = id } writeJSON(w, http.StatusCreated, tdclient.Team{ID: id, Name: req.Name}) default: if id, rest, ok := parseTeamSubPath(r.URL.Path); ok { f.handleTeamSubPath(w, r, id, rest) return } w.WriteHeader(http.StatusNotFound) } } // handleTeamSubPath answers everything under /api/teams/{id}. rest is whatever // parseTeamSubPath found after "/api/teams/{id}" -- "" for the bare resource. func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) { switch { case rest == "" && r.Method == http.MethodPut: var req struct { Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&req) oldName, exists := f.teamNames[id] if !exists { w.WriteHeader(http.StatusNotFound) return } if other, taken := f.teams[req.Name]; taken && other != id { writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"}) return } delete(f.teams, oldName) f.teamNames[id] = req.Name f.teams[req.Name] = id w.WriteHeader(http.StatusNoContent) case rest == "" && r.Method == http.MethodDelete: name, exists := f.teamNames[id] if !exists { w.WriteHeader(http.StatusNotFound) return } delete(f.teams, name) delete(f.teamNames, id) f.teamDelete[id] = true w.WriteHeader(http.StatusNoContent) case rest == "/oidc-groups" && r.Method == http.MethodPut: var req struct { MemberGroup string `json:"member_group"` OwnerGroup string `json:"owner_group"` } _ = json.NewDecoder(r.Body).Decode(&req) if _, exists := f.teamNames[id]; !exists { w.WriteHeader(http.StatusNotFound) return } f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup} w.WriteHeader(http.StatusNoContent) case rest == "/escalation" && r.Method == http.MethodPut: var req tdclient.SetEscalationRequest _ = json.NewDecoder(r.Body).Decode(&req) for _, l := range req.Levels { for _, t := range l.Targets { if t.Username != "" && f.unknownUsers[t.Username] { writeJSON(w, http.StatusBadRequest, map[string]string{errJSONKey: fmt.Sprintf("unknown user %q", t.Username)}) return } } } f.escalation[id] = req w.WriteHeader(http.StatusNoContent) case rest == deadmanSwitchesPath || strings.HasPrefix(rest, deadmanSwitchesPath+"/"): f.handleDeadmanSubPath(w, r, id, rest) case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"): f.handleIntegrationSubPath(w, r, id, rest) default: w.WriteHeader(http.StatusNotFound) } } // handleDeadmanSubPath answers GET/POST /api/teams/{id}/deadman/switches and // PUT/DELETE .../deadman/switches/{switchID} -- split out of // handleTeamSubPath for the same gocyclo reason as handleIntegrationSubPath. func (f *fakeTerdutServer) handleDeadmanSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) { switch { case rest == deadmanSwitchesPath && r.Method == http.MethodGet: existing := f.switches[id] out := make([]tdclient.DeadmanSwitch, 0, len(existing)) for _, s := range existing { out = append(out, s) } writeJSON(w, http.StatusOK, out) case rest == deadmanSwitchesPath && r.Method == http.MethodPost: var req deadmanSwitchFakeRequest _ = json.NewDecoder(r.Body).Decode(&req) name := req.Name for _, s := range f.switches[id] { if s.Name == name { writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a switch with that name already exists in this team"}) return } } f.nextSwitchID++ switchID := f.nextSwitchID sw := tdclient.DeadmanSwitch{ ID: switchID, Name: name, Matcher: req.Matcher, TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity, } if f.switches[id] == nil { f.switches[id] = map[int64]tdclient.DeadmanSwitch{} } f.switches[id][switchID] = sw writeJSON(w, http.StatusCreated, sw) case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodPut: switchID, ok := parseTrailingID(rest, "/deadman/switches/") if !ok { w.WriteHeader(http.StatusNotFound) return } if _, exists := f.switches[id][switchID]; !exists { w.WriteHeader(http.StatusNotFound) return } var req deadmanSwitchFakeRequest _ = json.NewDecoder(r.Body).Decode(&req) name := req.Name if name == "" { name = f.switches[id][switchID].Name } f.switches[id][switchID] = tdclient.DeadmanSwitch{ ID: switchID, Name: name, Matcher: req.Matcher, TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity, } w.WriteHeader(http.StatusNoContent) case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodDelete: switchID, ok := parseTrailingID(rest, "/deadman/switches/") if !ok { w.WriteHeader(http.StatusNotFound) return } if _, exists := f.switches[id][switchID]; !exists { w.WriteHeader(http.StatusNotFound) return } delete(f.switches[id], switchID) f.switchDelete[switchID] = true w.WriteHeader(http.StatusNoContent) 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) } } // deadmanSwitchFakeRequest mirrors tdclient's own (unexported) // deadmanSwitchRequest -- the fake needs its own copy to decode the same // wire shape without reaching across package boundaries for an internal type. type deadmanSwitchFakeRequest struct { Name string `json:"name,omitempty"` Matcher string `json:"matcher"` TimeoutSeconds int64 `json:"timeout_seconds"` Severity string `json:"severity"` } // parseTeamSubPath splits "/api/teams/{id}" from anything after it -- // "" for an exact match, "/oidc-groups", "/escalation", "/deadman/switches" // or "/deadman/switches/{switchID}" otherwise. Doesn't itself validate the // suffix; handleTeamSubPath's own switch does that. func parseTeamSubPath(path string) (id int64, rest string, ok bool) { const prefix = "/api/teams/" if !strings.HasPrefix(path, prefix) { return 0, "", false } trimmed := path[len(prefix):] parts := strings.SplitN(trimmed, "/", 2) parsedID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return 0, "", false } if len(parts) == 1 { return parsedID, "", true } return parsedID, "/" + parts[1], true } // parseTrailingID parses the numeric id after prefix within rest, e.g. // parseTrailingID("/deadman/switches/7", "/deadman/switches/") -> 7, true. func parseTrailingID(rest, prefix string) (id int64, ok bool) { parsedID, err := strconv.ParseInt(strings.TrimPrefix(rest, prefix), 10, 64) if err != nil { return 0, false } return parsedID, true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }