// Package fakeglpi is a stateful in-memory GLPI REST fake used by the // test suite and the smoke run. The real CMDB is NEVER contacted. // It implements exactly the endpoints the glpi client uses, enforces // App-Token + session auth on every one of them, and records every // request (headers included) so tests can assert the header discipline. // // GLPI quirks modeled faithfully: // - POST create endpoints return an ARRAY: [{"id":N,"message":"..."}]. // - Change create takes {"input":{...}} (object); ITILFollowup REQUIRES // {"input":[{...}]} (array of objects) and rejects the object form. // - search endpoints return data rows OBJECTS KEYED BY FIELD-ID STRING // ({"1":"name","2":7,"12":3}) when forcedisplay is used. // - initSession -> {"session_token":"..."}; killSession invalidates it. // - changeActiveProfile switches the session's active profile; with // RequireChangeProfile set, change creation is rejected (403 // ERROR_RIGHT_MISSING) until the right profile is active. // // Error responses deliberately echo the presented token back in the // body: any test that survives that proves the client never surfaces // response bodies (token-redaction guarantee). package fakeglpi import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "sort" "strconv" "strings" "sync" ) // criteria is one search criterion from the criteria query parameter. type criteria struct { Field int `json:"field"` Searchtype string `json:"searchtype"` Value any `json:"value"` } // Change status constants (GLPI change lifecycle). const ( StatusNew = 1 StatusEvaluation = 2 StatusApproval = 3 StatusTest = 4 StatusQualification = 5 StatusWaiting = 6 StatusAccepted = 7 StatusAssigned = 8 StatusPlanned = 9 StatusPending = 10 StatusSolved = 11 StatusClosed = 12 ) // Request is one recorded exchange (auth checked, body captured). type Request struct { Method string Path string // path without query, trailing slash normalized Query string // raw query Body string // What was presented in each auth header (header-discipline tests). AppToken string AuthHeader string // Authorization header (initSession only) SessionToken string // Session-Token header (everything after init) } // FailSpec pins the next response (error-mapping tests). The body is a // format string: %s is replaced with the token presented on the request. type FailSpec struct { Status int Body string } // Profile is one GLPI profile of the logged-in user. type Profile struct { ID int `json:"id"` Name string `json:"name"` IsActive bool `json:"is_active"` } // Change is the stored change (server side). type Change struct { ID int `json:"id"` Name string `json:"name"` Content string `json:"content"` Status int `json:"status"` Urgency int `json:"urgency"` Impact int `json:"impact"` Date string `json:"date"` DateMod string `json:"date_mod"` } // Followup is one ITILFollowup attached to a change. type Followup struct { ID int `json:"id"` Itemtype string `json:"itemtype"` ItemsID int `json:"items_id"` Content string `json:"content"` Date string `json:"date"` } // Server is the fake GLPI. type Server struct { URL string AppToken string UserToken string // RequireChangeProfile, when > 0, makes change creation answer 403 // ERROR_RIGHT_MISSING unless the session's active profile equals it // (the agent-mode switch path). RequireChangeProfile int // Fail, when non-nil, is returned instead of normal handling; it is // consumed by the first request that sees it. Fail *FailSpec mu sync.Mutex srv *httptest.Server requests []Request sessions map[string]bool activeProfile map[string]int nextSess int profiles []Profile changes map[int]*Change followups map[int][]Followup items map[string]map[int]map[string]any nextID int } // New starts a fake on a random port with GLPI's default profiles and a // few demo CIs (Computers/Monitors) for search. func New(appToken, userToken string) *Server { s := newServer(appToken, userToken) mux := http.NewServeMux() mux.HandleFunc("/", s.handler) s.srv = httptest.NewServer(mux) s.URL = s.srv.URL return s } // ListenAndServe runs the fake on a fixed address (the smoke run boots // it in a container). requireChangeProfile sets the agent-mode gate. func ListenAndServe(addr, appToken, userToken string, requireChangeProfile int) error { s := newServer(appToken, userToken) s.RequireChangeProfile = requireChangeProfile seedDemo(s) mux := http.NewServeMux() mux.HandleFunc("/", s.handler) return http.ListenAndServe(addr, mux) } func newServer(appToken, userToken string) *Server { return &Server{ AppToken: appToken, UserToken: userToken, sessions: map[string]bool{}, activeProfile: map[string]int{}, profiles: []Profile{ {ID: 4, Name: "Self-Service"}, {ID: 5, Name: "Hotliner"}, {ID: 6, Name: "Super-admin", IsActive: true}, }, changes: map[int]*Change{}, followups: map[int][]Followup{}, items: map[string]map[int]map[string]any{}, nextID: 100, } } // seedDemo adds the CIs the smoke run searches for. func seedDemo(s *Server) { s.AddItem("Computer", map[string]any{"name": "smoke-web-01", "serial": "SMOKEWEB01"}) s.AddItem("Computer", map[string]any{"name": "smoke-db-01", "serial": "SMOKEDB01"}) s.AddItem("Monitor", map[string]any{"name": "smoke-mon-01"}) } // Close shuts the fake down. func (s *Server) Close() { s.srv.Close() } // Requests returns a copy of the recorded exchanges. func (s *Server) Requests() []Request { s.mu.Lock() defer s.mu.Unlock() out := make([]Request, len(s.requests)) copy(out, s.requests) return out } // Sessions returns the still-valid session tokens (sorted). func (s *Server) Sessions() []string { s.mu.Lock() defer s.mu.Unlock() out := make([]string, 0, len(s.sessions)) for tok := range s.sessions { out = append(out, tok) } sort.Strings(out) return out } // SessionCount reports the number of valid sessions. func (s *Server) SessionCount() int { s.mu.Lock() defer s.mu.Unlock() return len(s.sessions) } // AddChange seeds a change directly (bypassing REST) and returns its id. func (s *Server) AddChange(c Change) int { s.mu.Lock() defer s.mu.Unlock() s.nextID++ c.ID = s.nextID if c.Status == 0 { c.Status = StatusNew } if c.Date == "" { c.Date = "2026-09-03T12:00:00Z" } if c.DateMod == "" { c.DateMod = c.Date } cc := c s.changes[c.ID] = &cc return c.ID } // Change returns a copy of a stored change (for assertions). func (s *Server) Change(id int) (Change, bool) { s.mu.Lock() defer s.mu.Unlock() c, ok := s.changes[id] if !ok { return Change{}, false } return *c, true } // Followups returns the followups attached to a change (for assertions). func (s *Server) Followups(changeID int) []Followup { s.mu.Lock() defer s.mu.Unlock() out := make([]Followup, len(s.followups[changeID])) copy(out, s.followups[changeID]) return out } // AddItem seeds a CI (any itemtype) directly and returns its id. func (s *Server) AddItem(itemtype string, obj map[string]any) int { s.mu.Lock() defer s.mu.Unlock() s.nextID++ id := s.nextID obj["id"] = id if s.items[itemtype] == nil { s.items[itemtype] = map[int]map[string]any{} } s.items[itemtype][id] = obj return id } // Item returns a copy of a stored CI (for assertions). func (s *Server) Item(itemtype string, id int) (map[string]any, bool) { s.mu.Lock() defer s.mu.Unlock() obj, ok := s.items[itemtype][id] if !ok { return nil, false } out := map[string]any{} for k, v := range obj { out[k] = v } return out, true } // presented picks the token the request showed (the one an echo may // leak into an error body). func presented(r *http.Request) string { if v := r.Header.Get("Session-Token"); v != "" { return v } if v := r.Header.Get("Authorization"); v != "" { return v } return r.Header.Get("App-Token") } func (s *Server) handler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) present := presented(r) s.mu.Lock() // Fail overrides everything (echoes the presented token in the body). if s.Fail != nil { spec := *s.Fail s.Fail = nil s.record(r, body) s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(spec.Status) fmt.Fprintf(w, spec.Body, present) return } // App-Token is required on EVERY endpoint. if r.Header.Get("App-Token") != s.AppToken { s.record(r, nil) s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `[{"ERROR_APP_TOKEN_PARAMETERS_MISSING":"presented %s"}]`, present) return } // GLPI serves every endpoint under /apirest.php; accept both the // prefixed and bare forms. path := strings.TrimSuffix(r.URL.Path, "/") path = strings.TrimPrefix(path, "/apirest.php") if path == "" { path = "/" } // initSession is the only endpoint that authenticates with the user // token (Authorization: user_token ...); everything else needs a // live Session-Token. if path == "/initSession" { respStatus, respBody := s.initSession(r) s.record(r, body) s.mu.Unlock() writeJSON(w, respStatus, respBody) return } tok := r.Header.Get("Session-Token") if !s.sessions[tok] { s.record(r, nil) s.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `[{"ERROR_SESSION_TOKEN_MISSING":"presented %s"}]`, present) return } var ( respStatus = http.StatusOK respBody string ) q := r.URL.Query() switch { case (r.Method == http.MethodGet || r.Method == http.MethodPost) && path == "/killSession": delete(s.sessions, tok) delete(s.activeProfile, tok) respBody = `true` case r.Method == http.MethodGet && path == "/getMyProfiles": respStatus, respBody = s.getMyProfiles(tok) case r.Method == http.MethodPost && path == "/changeActiveProfile": respStatus, respBody = s.changeActiveProfile(tok, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/Profile/"): respStatus, respBody = s.getProfile(tok, trimPrefixInt(path, "/Profile/")) case r.Method == http.MethodPost && path == "/change": respStatus, respBody = s.createChange(tok, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/change/"): respStatus, respBody = s.getChange(trimPrefixInt(path, "/change/")) case r.Method == http.MethodPut && strings.HasPrefix(path, "/change/"): respStatus, respBody = s.updateChange(tok, trimPrefixInt(path, "/change/"), body) case r.Method == http.MethodPost && path == "/ITILFollowup": respStatus, respBody = s.createFollowup(body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/search/"): respStatus, respBody = s.search(itemtypeOf(path), q) case len(strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)) == 2: respStatus, respBody = s.getItem(path) default: respStatus, respBody = http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } s.record(r, body) s.mu.Unlock() writeJSON(w, respStatus, respBody) } func writeJSON(w http.ResponseWriter, status int, body string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if body != "" { io.WriteString(w, body) } } // record appends one exchange; callers hold s.mu. func (s *Server) record(r *http.Request, body []byte) { s.requests = append(s.requests, Request{ Method: r.Method, Path: strings.TrimSuffix(r.URL.Path, "/"), Query: r.URL.RawQuery, Body: string(body), AppToken: r.Header.Get("App-Token"), AuthHeader: r.Header.Get("Authorization"), SessionToken: r.Header.Get("Session-Token"), }) } func (s *Server) initSession(r *http.Request) (int, string) { if auth := r.Header.Get("Authorization"); auth != "user_token "+s.UserToken { return http.StatusBadRequest, fmt.Sprintf(`[{"ERROR_GLPI_LOGIN":"login %s refused"}]`, auth) } s.nextSess++ tok := fmt.Sprintf("sess-%d", s.nextSess) s.sessions[tok] = true s.activeProfile[tok] = 6 // Super-admin is the default active profile return http.StatusOK, `{"session_token":"` + tok + `"}` } func (s *Server) getMyProfiles(tok string) (int, string) { out := make([]Profile, len(s.profiles)) copy(out, s.profiles) for i := range out { out[i].IsActive = out[i].ID == s.activeProfile[tok] } return jsonReply(out) } func (s *Server) changeActiveProfile(tok string, body []byte) (int, string) { var p struct { ProfilesID int `json:"profiles_id"` } if err := json.Unmarshal(body, &p); err != nil || p.ProfilesID == 0 { return http.StatusBadRequest, `[{"ERROR_ARGUMENTS":true}]` } found := false for _, pr := range s.profiles { if pr.ID == p.ProfilesID { found = true } } if !found { return http.StatusBadRequest, `[{"ERROR_PROFILE_NOT_FOUND":true}]` } s.activeProfile[tok] = p.ProfilesID return http.StatusOK, `true` } func (s *Server) getProfile(tok string, id int) (int, string) { for _, pr := range s.profiles { if pr.ID == id { p := pr p.IsActive = p.ID == s.activeProfile[tok] return jsonReply(p) } } return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } // createChange enforces the agent profile gate, the object-input // contract, and GLPI's array reply shape. func (s *Server) createChange(tok string, body []byte) (int, string) { if s.RequireChangeProfile > 0 && s.activeProfile[tok] != s.RequireChangeProfile { return http.StatusForbidden, `[{"ERROR_RIGHT_MISSING":true}]` } input, ok := inputObject(body) if !ok { return http.StatusBadRequest, `[{"ERROR_INPUT_OBJECT_EXPECTED":true}]` } var c Change if err := json.Unmarshal(input, &c); err != nil { return http.StatusBadRequest, `[{"ERROR_ARGUMENTS":true}]` } if c.Name == "" { return http.StatusBadRequest, `[{"ERROR_GLPI_ADD":true}]` } s.nextID++ c.ID = s.nextID if c.Status == 0 { c.Status = StatusNew } c.Date = "2026-09-03T12:00:00Z" c.DateMod = c.Date cc := c s.changes[c.ID] = &cc return http.StatusCreated, fmt.Sprintf(`[{"id":%d,"message":"change created"}]`, c.ID) } func (s *Server) getChange(id int) (int, string) { c, ok := s.changes[id] if !ok { return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } return jsonReply(*c) } // updateChange applies a partial update and answers GLPI's array shape // [{"":true,"message":""}]. func (s *Server) updateChange(tok string, id int, body []byte) (int, string) { if s.RequireChangeProfile > 0 && s.activeProfile[tok] != s.RequireChangeProfile { return http.StatusForbidden, `[{"ERROR_RIGHT_MISSING":true}]` } c, ok := s.changes[id] if !ok { return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } input, ok := inputObject(body) if !ok { return http.StatusBadRequest, `[{"ERROR_INPUT_OBJECT_EXPECTED":true}]` } var p Change if err := json.Unmarshal(input, &p); err != nil { return http.StatusBadRequest, `[{"ERROR_ARGUMENTS":true}]` } if p.Name != "" { c.Name = p.Name } if p.Content != "" { c.Content = p.Content } if p.Status != 0 { if p.Status < StatusNew || p.Status > StatusClosed { return http.StatusBadRequest, `[{"ERROR_GLPI_UPDATE":true}]` } c.Status = p.Status } if p.Urgency != 0 { c.Urgency = p.Urgency } if p.Impact != 0 { c.Impact = p.Impact } c.DateMod = "2026-09-03T13:00:00Z" return http.StatusOK, fmt.Sprintf(`[{"%d":true,"message":""}]`, id) } // createFollowup enforces the ARRAY-input contract ({"input":[{...}]}); // the object form is rejected, mirroring the real ITILFollowup endpoint. func (s *Server) createFollowup(body []byte) (int, string) { var probe struct { Input json.RawMessage `json:"input"` } if err := json.Unmarshal(body, &probe); err != nil || len(probe.Input) == 0 { return http.StatusBadRequest, `[{"ERROR_ARGUMENTS":true}]` } if bytes.TrimSpace(probe.Input)[0] != '[' { return http.StatusBadRequest, `[{"ERROR_INPUT_ARRAY_EXPECTED":true}]` } var ins []Followup if err := json.Unmarshal(probe.Input, &ins); err != nil { return http.StatusBadRequest, `[{"ERROR_ARGUMENTS":true}]` } var created []map[string]any for _, in := range ins { if in.Itemtype != "Change" || in.Content == "" { return http.StatusBadRequest, `[{"ERROR_GLPI_ADD":true}]` } if _, ok := s.changes[in.ItemsID]; !ok { return http.StatusBadRequest, `[{"ERROR_GLPI_ADD":true}]` } s.nextID++ in.ID = s.nextID in.Date = "2026-09-03T13:00:00Z" s.followups[in.ItemsID] = append(s.followups[in.ItemsID], in) created = append(created, map[string]any{"id": in.ID, "message": "followup added"}) } if created == nil { created = []map[string]any{} } // Create endpoints answer 201 with the array reply shape. return http.StatusCreated, mustJSON(created) } func mustJSON(v any) string { b, err := json.Marshal(v) if err != nil { panic(err) } return string(b) } // search serves /search/: criteria filtering plus rows keyed // by forcedisplay field-id strings ({"1":"name","2":7,"12":3}). func (s *Server) search(itemtype string, q url.Values) (int, string) { if itemtype != "Change" && s.items[itemtype] == nil { return http.StatusBadRequest, `[{"ERROR_GLPI_SEARCH":true}]` } base := map[int]map[string]any{} var ids []int appendRow := func(id int, row map[string]any) { base[id] = row ids = append(ids, id) } if itemtype == "Change" { for id, c := range s.changes { appendRow(id, map[string]any{"1": c.Name, "2": c.ID, "12": c.Status}) } } else { for id, obj := range s.items[itemtype] { name, _ := obj["name"].(string) appendRow(id, map[string]any{"1": name, "2": id}) } } sort.Ints(ids) var criteriaList []criteria if c := q.Get("criteria"); c != "" { if err := json.Unmarshal([]byte(c), &criteriaList); err != nil { return http.StatusBadRequest, `[{"ERROR_GLPI_SEARCH":true}]` } } rows := []map[string]any{} for _, id := range ids { row := base[id] if !matchCriteria(row, criteriaList) { continue } forced := q["forcedisplay[]"] if len(forced) == 0 { forced = []string{"1", "2"} } projected := map[string]any{} for _, f := range forced { if v, ok := row[f]; ok { projected[f] = v } } rows = append(rows, projected) } out := map[string]any{ "totalcount": len(rows), "count": len(rows), "sort": 1, "order": "ASC", "data": rows, } return jsonReply(out) } func matchCriteria(row map[string]any, criteriaList []criteria) bool { for _, cr := range criteriaList { val, ok := row[strconv.Itoa(cr.Field)] if !ok { return false } switch cr.Searchtype { case "equals": if asFloat(val) != asFloat(cr.Value) { return false } case "contains": vs, _ := cr.Value.(string) if !strings.Contains(strings.ToLower(fmt.Sprint(val)), strings.ToLower(vs)) { return false } default: return false } } return true } func asFloat(v any) float64 { switch n := v.(type) { case float64: return n case int: return float64(n) case string: f, _ := strconv.ParseFloat(n, 64) return f default: return -1 } } // getItem serves GET // for seeded CI types. func (s *Server) getItem(path string) (int, string) { parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2) itemtype := parts[0] id, err := strconv.Atoi(parts[1]) if err != nil { return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } obj, ok := s.items[itemtype][id] if !ok { return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } return jsonReply(obj) } // inputObject extracts the "input" member and reports whether it is a // JSON object (Change family) — the array form is a distinct error. func inputObject(body []byte) (json.RawMessage, bool) { var probe struct { Input json.RawMessage `json:"input"` } if err := json.Unmarshal(body, &probe); err != nil || len(probe.Input) == 0 { return nil, false } trimmed := bytes.TrimSpace(probe.Input) if len(trimmed) == 0 || trimmed[0] != '{' { return nil, false } return trimmed, true } func trimPrefixInt(path, prefix string) int { n, _ := strconv.Atoi(strings.TrimPrefix(path, prefix)) return n } func itemtypeOf(path string) string { return strings.TrimPrefix(path, "/search/") } func jsonReply(v any) (int, string) { b, err := json.Marshal(v) if err != nil { return http.StatusInternalServerError, `[{"ERROR_MARSHAL":true}]` } return http.StatusOK, string(b) }