// Package glpi is a stdlib-only client for the GLPI REST API // (apirest.php). Tests run against the stateful in-process fake // (internal/fakeglpi); the real CMDB is NEVER contacted. package glpi_test import ( "context" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "testing" "time" "git.knownelement.com/ukrrs/mopac-glpi-go/glpi" "git.knownelement.com/ukrrs/mopac-glpi-go/internal/fakeglpi" ) const ( appTok = "fake-app-token-0123456789" userTok = "fake-user-token-0123456789" sessSeed = "sess-" // fake session tokens carry this prefix ) func newClient(t *testing.T) (*glpi.Client, *fakeglpi.Server) { t.Helper() srv := fakeglpi.New(appTok, userTok) t.Cleanup(srv.Close) c := glpi.New(glpi.Config{BaseURL: srv.URL, AppToken: appTok, UserToken: userTok, Timeout: 5 * time.Second}) return c, srv } func lastReq(t *testing.T, srv *fakeglpi.Server) fakeglpi.Request { t.Helper() reqs := srv.Requests() if len(reqs) == 0 { t.Fatal("no requests recorded") } return reqs[len(reqs)-1] } // --- session lifecycle + header discipline --------------------------------- // GLPI auth is three headers: App-Token on EVERY request, Authorization // user_token ONLY on initSession, Session-Token on everything after. func TestSessionHeaderDiscipline(t *testing.T) { c, srv := newClient(t) if _, err := c.GetMyProfiles(context.Background()); err != nil { t.Fatalf("GetMyProfiles: %v", err) } initReq := srv.Requests()[0] if initReq.Method != "POST" || initReq.Path != "/initSession" { t.Fatalf("first request = %+v, want POST /initSession", initReq) } if initReq.AuthHeader != "user_token "+userTok { t.Errorf("initSession Authorization = %q", initReq.AuthHeader) } if initReq.SessionToken != "" { t.Errorf("initSession must not carry a Session-Token: %q", initReq.SessionToken) } for i, r := range srv.Requests() { if r.AppToken != appTok { t.Errorf("request %d App-Token = %q, want the app token", i, r.AppToken) } if i > 0 { if r.AuthHeader != "" { t.Errorf("request %d carries Authorization after init: %q", i, r.AuthHeader) } if !strings.HasPrefix(r.SessionToken, sessSeed) { t.Errorf("request %d Session-Token = %q, want a fake session token", i, r.SessionToken) } } } } func TestInitSessionParsesSessionToken(t *testing.T) { c, _ := newClient(t) if err := c.InitSession(context.Background()); err != nil { t.Fatalf("InitSession: %v", err) } if err := c.InitSession(context.Background()); err != nil { t.Fatalf("second InitSession must reuse/be idempotent: %v", err) } } func TestKillSessionAndReinit(t *testing.T) { c, srv := newClient(t) if _, err := c.GetMyProfiles(context.Background()); err != nil { t.Fatalf("warmup: %v", err) } if got := srv.SessionCount(); got != 1 { t.Fatalf("sessions = %d, want 1", got) } if err := c.KillSession(context.Background()); err != nil { t.Fatalf("KillSession: %v", err) } if got := srv.SessionCount(); got != 0 { t.Fatalf("sessions after kill = %d, want 0", got) } // The next call transparently re-inits with a fresh session. if _, err := c.GetMyProfiles(context.Background()); err != nil { t.Fatalf("call after kill: %v", err) } if got := srv.SessionCount(); got != 1 { t.Fatalf("sessions after re-init = %d, want 1", got) } } // --- change create (array response + object input) -------------------------- func TestCreateChangeRoundTrip(t *testing.T) { c, srv := newClient(t) id, err := c.CreateChange(context.Background(), "Quota: per-identity accounting", "

scope body

", 3, 4) if err != nil { t.Fatalf("CreateChange: %v", err) } if id == 0 { t.Fatal("created id = 0") } // The POST body must carry input as a single OBJECT (GLPI Change // contract; ITILFollowup is the array-flavored endpoint). body := lastReq(t, srv).Body if !strings.HasPrefix(body, `{"input":{`) { t.Fatalf("POST body = %s, want {\"input\":{...}} (object, not array)", body) } var p struct { Input struct { Name string `json:"name"` Content string `json:"content"` Urgency int `json:"urgency"` Impact int `json:"impact"` } `json:"input"` } if err := json.Unmarshal([]byte(body), &p); err != nil { t.Fatalf("POST body: %v", err) } if p.Input.Name != "Quota: per-identity accounting" || p.Input.Content != "

scope body

" || p.Input.Urgency != 3 || p.Input.Impact != 4 { t.Errorf("POST input = %+v", p.Input) } stored, ok := srv.Change(id) if !ok || stored.Name != "Quota: per-identity accounting" || stored.Urgency != 3 || stored.Impact != 4 { t.Errorf("stored = %+v", stored) } if stored.Status != glpi.StatusNew { t.Errorf("default status = %d, want %d (new)", stored.Status, glpi.StatusNew) } got, err := c.GetChange(context.Background(), id) if err != nil { t.Fatalf("GetChange: %v", err) } if got.ID != id || got.Name != stored.Name || got.Content != "

scope body

" || got.Status != glpi.StatusNew || got.Urgency != 3 || got.Impact != 4 { t.Errorf("GetChange = %+v", got) } if _, err := c.GetChange(context.Background(), 424242); !errors.Is(err, glpi.ErrNotFound) { t.Errorf("missing change err = %v, want ErrNotFound", err) } } func TestCreateChangeValidation(t *testing.T) { c, _ := newClient(t) tests := []struct { name string title string urgency int impact int }{ {"empty title", "", 3, 3}, {"urgency too low", "x", 0, 3}, {"urgency too high", "x", 6, 3}, {"impact too low", "x", 3, 0}, {"impact too high", "x", 3, 6}, } for _, tt := range tests { if _, err := c.CreateChange(context.Background(), tt.title, "c", tt.urgency, tt.impact); !errors.Is(err, glpi.ErrValidation) { t.Errorf("%s: err = %v, want ErrValidation", tt.name, err) } } } // The fake itself must enforce the array-vs-object input quirks on the // wire: Change takes {"input":{...}}, ITILFollowup REQUIRES // {"input":[{...}]} and rejects the object form. func TestFakeInputShapeQuirks(t *testing.T) { c, srv := newClient(t) id, err := c.CreateChange(context.Background(), "seed", "c", 3, 3) if err != nil || id == 0 { t.Fatalf("seed: %d %v", id, err) } sess := srv.Sessions()[0] post := func(path, body string) int { t.Helper() req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(body)) req.Header.Set("App-Token", appTok) req.Header.Set("Session-Token", sess) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST %s: %v", path, err) } defer resp.Body.Close() return resp.StatusCode } if got := post("/change/", `{"input":[{"name":"array form"}]}`); got != 400 { t.Errorf("change create with array input = %d, want 400", got) } if got := post("/ITILFollowup/", `{"input":{"content":"object form"}}`); got != 400 { t.Errorf("followup with object input = %d, want 400", got) } body := fmt.Sprintf(`{"input":[{"itemtype":"Change","items_id":%d,"content":"ok"}]}`, id) if got := post("/ITILFollowup/", body); got != 201 { t.Errorf("followup with array input = %d, want 201", got) } } // --- list (keyed search rows) ------------------------------------------------ func TestListChanges(t *testing.T) { c, srv := newClient(t) a := srv.AddChange(fakeglpi.Change{Name: "new one", Status: glpi.StatusNew, Urgency: 3, Impact: 3}) b := srv.AddChange(fakeglpi.Change{Name: "solved one", Status: glpi.StatusSolved, Urgency: 3, Impact: 3}) rows, err := c.ListChanges(context.Background(), 0) if err != nil { t.Fatalf("ListChanges: %v", err) } if len(rows) != 2 || rows[0].ID != a || rows[1].ID != b { t.Fatalf("rows = %+v, want ids %d,%d", rows, a, b) } if rows[0].Name != "new one" || rows[0].Status != glpi.StatusNew { t.Errorf("row0 = %+v", rows[0]) } wantQ := "forcedisplay%5B%5D=1&forcedisplay%5B%5D=2&forcedisplay%5B%5D=12" if q := lastReq(t, srv).Query; q != wantQ { t.Errorf("query = %q, want %q", q, wantQ) } rows, err = c.ListChanges(context.Background(), glpi.StatusNew) if err != nil { t.Fatalf("ListChanges(status): %v", err) } if len(rows) != 1 || rows[0].ID != a { t.Fatalf("filtered rows = %+v, want only %d", rows, a) } q := lastReq(t, srv).Query for _, frag := range []string{ `criteria=%5B%7B%22field%22%3A12`, // criteria=[{"field":12 `%22searchtype%22%3A%22equals%22`, // ,"searchtype":"equals" `%22value%22%3A1%7D%5D`, // ,"value":1}] "forcedisplay%5B%5D=1", } { if !strings.Contains(q, frag) { t.Errorf("query %q missing %q", q, frag) } } } // --- transition --------------------------------------------------------------- func TestTransitionChange(t *testing.T) { c, srv := newClient(t) id := srv.AddChange(fakeglpi.Change{Name: "flow", Status: glpi.StatusNew, Urgency: 3, Impact: 3}) if err := c.TransitionChange(context.Background(), id, glpi.StatusSolved); err != nil { t.Fatalf("TransitionChange: %v", err) } if body := lastReq(t, srv).Body; body != `{"input":{"status":11}}` { t.Errorf("PUT body = %s", body) } if got, _ := srv.Change(id); got.Status != glpi.StatusSolved { t.Errorf("stored status = %d, want %d", got.Status, glpi.StatusSolved) } err := c.TransitionChange(context.Background(), 424242, glpi.StatusSolved) if !errors.Is(err, glpi.ErrNotFound) || !strings.Contains(err.Error(), "http 404") { t.Errorf("missing-change transition err = %v", err) } } // --- followup (array-of-objects input) ---------------------------------------- func TestAddFollowup(t *testing.T) { c, srv := newClient(t) id := srv.AddChange(fakeglpi.Change{Name: "with note", Status: glpi.StatusNew, Urgency: 3, Impact: 3}) if err := c.AddFollowup(context.Background(), id, "REPORT delivered: smoke"); err != nil { t.Fatalf("AddFollowup: %v", err) } wantBody := fmt.Sprintf(`{"input":[{"content":"REPORT delivered: smoke","items_id":%d,"itemtype":"Change"}]}`, id) if body := lastReq(t, srv).Body; body != wantBody { t.Errorf("POST body = %s, want %s", body, wantBody) } fups := srv.Followups(id) if len(fups) != 1 || fups[0].Content != "REPORT delivered: smoke" || fups[0].Itemtype != "Change" { t.Errorf("followups = %+v", fups) } if err := c.AddFollowup(context.Background(), 424242, "nowhere"); !errors.Is(err, glpi.ErrValidation) { t.Errorf("followup on missing change err = %v, want ErrValidation", err) } } // --- CI search + raw item fetch ---------------------------------------------- func TestSearchCI(t *testing.T) { c, srv := newClient(t) web := srv.AddItem("Computer", map[string]any{"name": "web-01", "serial": "ABC123"}) _ = srv.AddItem("Computer", map[string]any{"name": "db-01"}) _ = srv.AddItem("Monitor", map[string]any{"name": "web-cam"}) rows, err := c.SearchCI(context.Background(), "Computer", "web") if err != nil { t.Fatalf("SearchCI: %v", err) } if len(rows) != 1 || rows[0].ID != web || rows[0].Name != "web-01" { t.Fatalf("rows = %+v, want the web-01 computer", rows) } if rows[0].Fields["1"] != "web-01" { t.Errorf("row fields = %+v, want field \"1\" keyed name", rows[0].Fields) } q := lastReq(t, srv).Query for _, frag := range []string{`%22field%22%3A1`, `%22searchtype%22%3A%22contains%22`, `%22value%22%3A%22web%22`, "forcedisplay%5B%5D=1", "forcedisplay%5B%5D=2"} { if !strings.Contains(q, frag) { t.Errorf("query %q missing %q", q, frag) } } if rows, err := c.SearchCI(context.Background(), "Computer", "no-such-thing"); err != nil || len(rows) != 0 { t.Errorf("no-match search = %+v err %v, want empty", rows, err) } } func TestGetItem(t *testing.T) { c, srv := newClient(t) id := srv.AddItem("Computer", map[string]any{"name": "web-01", "serial": "ABC123"}) raw, err := c.GetItem(context.Background(), "Computer", id) if err != nil { t.Fatalf("GetItem: %v", err) } if raw["name"] != "web-01" || raw["serial"] != "ABC123" { t.Errorf("raw = %+v", raw) } if _, err := c.GetItem(context.Background(), "Computer", 424242); !errors.Is(err, glpi.ErrNotFound) { t.Errorf("missing item err = %v, want ErrNotFound", err) } } // --- profiles + agent-mode switch ---------------------------------------------- func TestProfiles(t *testing.T) { c, srv := newClient(t) profs, err := c.GetMyProfiles(context.Background()) if err != nil { t.Fatalf("GetMyProfiles: %v", err) } if len(profs) != 3 { t.Fatalf("profiles = %+v, want 3", profs) } active := 0 for _, p := range profs { if p.IsActive { active = p.ID } } if active != 6 { // Super-admin is the fake's default active profile t.Errorf("default active profile = %d, want 6", active) } if err := c.ChangeActiveProfile(context.Background(), 5); err != nil { t.Fatalf("ChangeActiveProfile: %v", err) } if body := lastReq(t, srv).Body; body != `{"profiles_id":5}` { t.Errorf("changeActiveProfile body = %s", body) } profs, _ = c.GetMyProfiles(context.Background()) for _, p := range profs { if p.ID == 5 && !p.IsActive { t.Errorf("profile 5 not active after switch: %+v", profs) } if p.ID == 6 && p.IsActive { t.Errorf("profile 6 still active after switch: %+v", profs) } } if err := c.ChangeActiveProfile(context.Background(), 999); !errors.Is(err, glpi.ErrValidation) { t.Errorf("unknown profile err = %v, want ErrValidation", err) } } // THE agent-mode proof: with RequireChangeProfile=5 the fake rejects // change creation until the session's active profile IS 5 — the exact // Hotliner flow an agent env file drives via MGLPI_PROFILE_ID. func TestRequireProfileAgentMode(t *testing.T) { c, srv := newClient(t) srv.RequireChangeProfile = 5 _, err := c.CreateChange(context.Background(), "before switch", "c", 3, 3) if !errors.Is(err, glpi.ErrAuth) || !strings.Contains(err.Error(), "http 403") { t.Fatalf("pre-switch err = %v, want ErrAuth http 403", err) } if err := c.ChangeActiveProfile(context.Background(), 5); err != nil { t.Fatalf("ChangeActiveProfile(5): %v", err) } id, err := c.CreateChange(context.Background(), "after switch", "c", 3, 3) if err != nil || id == 0 { t.Fatalf("post-switch create = %d, %v; want success", id, err) } } // --- error mapping / transport ------------------------------------------------ func TestErrorMapping(t *testing.T) { tests := []struct { name string fail fakeglpi.FailSpec want error }{ {"400", fakeglpi.FailSpec{Status: 400, Body: `[{"ERROR_ARGUMENTS":"bad %s"}]`}, glpi.ErrValidation}, {"401", fakeglpi.FailSpec{Status: 401, Body: `[{"ERROR_SESSION_TOKEN_MISSING":"%s"}]`}, glpi.ErrAuth}, {"403", fakeglpi.FailSpec{Status: 403, Body: `[{"ERROR_RIGHT_MISSING":"%s"}]`}, glpi.ErrAuth}, {"404", fakeglpi.FailSpec{Status: 404, Body: `[{"ERROR_ITEM_NOT_FOUND":"%s"}]`}, glpi.ErrNotFound}, {"422", fakeglpi.FailSpec{Status: 422, Body: `[{"ERROR_GLPI_ADD":"%s"}]`}, glpi.ErrValidation}, {"500", fakeglpi.FailSpec{Status: 500, Body: `boom %s`}, glpi.ErrServer}, {"502", fakeglpi.FailSpec{Status: 502, Body: `bad gateway %s`}, glpi.ErrServer}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c, srv := newClient(t) // Warm the session first: Fail is consumed by the first // request that sees it, and the first request is always the // initSession — the mapping under test is the endpoint's. if _, err := c.GetMyProfiles(context.Background()); err != nil { t.Fatalf("warmup: %v", err) } srv.Fail = &tt.fail _, err := c.GetMyProfiles(context.Background()) if !errors.Is(err, tt.want) { t.Fatalf("err = %v, want %v", err, tt.want) } if !strings.Contains(err.Error(), "http "+strconv.Itoa(tt.fail.Status)) { t.Fatalf("err = %q, want embedded http status", err.Error()) } }) } } func TestUnreachable(t *testing.T) { srv := fakeglpi.New(appTok, userTok) url := srv.URL srv.Close() // port now dead c := glpi.New(glpi.Config{BaseURL: url, AppToken: appTok, UserToken: userTok, Timeout: 2 * time.Second}) _, err := c.GetMyProfiles(context.Background()) if !errors.Is(err, glpi.ErrUnreachable) { t.Fatalf("err = %v, want ErrUnreachable", err) } if strings.Contains(err.Error(), "http") { t.Fatalf("unreachable err should not fake an http status: %q", err.Error()) } } func TestMalformedResponse(t *testing.T) { c, srv := newClient(t) srv.Fail = &fakeglpi.FailSpec{Status: 200, Body: `<>`} if _, err := c.GetMyProfiles(context.Background()); !errors.Is(err, glpi.ErrMalformedResponse) { t.Fatalf("err = %v, want ErrMalformedResponse", err) } } // --- redaction: the fake echoes presented tokens in every error body; --- // --- surviving that proves the client never surfaces them. --- func TestTokensNeverLeak(t *testing.T) { t.Run("wrong user token rejected without echo", func(t *testing.T) { srv := fakeglpi.New(appTok, userTok) t.Cleanup(srv.Close) c := glpi.New(glpi.Config{BaseURL: srv.URL, AppToken: appTok, UserToken: "wrong-user-token", Timeout: 2 * time.Second}) _, err := c.GetMyProfiles(context.Background()) if !errors.Is(err, glpi.ErrAuth) { t.Fatalf("err = %v, want ErrAuth", err) } if strings.Contains(err.Error(), "wrong-user-token") || strings.Contains(err.Error(), userTok) || strings.Contains(err.Error(), appTok) { t.Fatalf("error leaks a token: %q", err.Error()) } }) t.Run("error bodies never surfaced", func(t *testing.T) { c, srv := newClient(t) srv.Fail = &fakeglpi.FailSpec{Status: 500, Body: `crashed holding %s %s-echo`} _, err := c.GetMyProfiles(context.Background()) if strings.Contains(err.Error(), "crashed") || strings.Contains(err.Error(), userTok) || strings.Contains(err.Error(), appTok) || strings.Contains(err.Error(), sessSeed) { t.Fatalf("err = %q, want sanitized one-liner", err.Error()) } }) t.Run("tokens travel only in headers", func(t *testing.T) { c, srv := newClient(t) id, err := c.CreateChange(context.Background(), "header audit", "c", 3, 3) if err != nil || id == 0 { t.Fatalf("CreateChange: %d %v", id, err) } if err := c.AddFollowup(context.Background(), id, "note"); err != nil { t.Fatalf("AddFollowup: %v", err) } for i, r := range srv.Requests() { for _, in := range []string{r.Body, r.Query, r.Path} { if strings.Contains(in, appTok) || strings.Contains(in, userTok) || strings.Contains(in, sessSeed) { t.Fatalf("request %d carries a token outside headers: %+v", i, r) } } } }) } // --- status helpers ----------------------------------------------------------- func TestStatusHelpers(t *testing.T) { if got := glpi.StatusName(glpi.StatusNew); got != "new" { t.Errorf("StatusName(1) = %q", got) } if id, ok := glpi.StatusID("solved"); !ok || id != glpi.StatusSolved { t.Errorf("StatusID(solved) = %d %v", id, ok) } if _, ok := glpi.StatusID("bogus"); ok { t.Error("StatusID(bogus) accepted") } }