Files
mopac-glpi-go/glpi_test.go
T

587 lines
20 KiB
Go

// 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"
"io"
"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", "<p>scope body</p>", 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 != "<p>scope body</p>" ||
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 != "<p>scope body</p>" ||
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)
}
for _, want := range []string{"Self-Service", "Hotliner", "Super-admin"} {
found := false
for _, p := range profs {
if p.Name == want {
found = true
}
}
if !found {
t.Errorf("profiles missing %q: %+v", want, profs)
}
}
// The active profile comes from GET /getActiveProfile, not from the
// myprofiles rows (which carry no is_active in the real API).
if active, err := c.GetActiveProfile(context.Background()); err != nil || active != 6 {
t.Fatalf("GetActiveProfile = %d, %v; want 6 (Super-admin default)", active, err)
}
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)
}
if active, err := c.GetActiveProfile(context.Background()); err != nil || active != 5 {
t.Errorf("GetActiveProfile after switch = %d, %v; want 5 (Hotliner)", active, err)
}
if err := c.ChangeActiveProfile(context.Background(), 999); !errors.Is(err, glpi.ErrValidation) {
t.Errorf("unknown profile err = %v, want ErrValidation", err)
}
}
// Fake-drift guard: the fake must serve GLPI's REAL WRAPPED
// getMyProfiles shape on the wire — {"myprofiles":[...]} — because the
// bare-array guess is exactly what broke against prod (2026-09-03).
func TestFakeMyProfilesWireShape(t *testing.T) {
c, srv := newClient(t)
if err := c.InitSession(context.Background()); err != nil {
t.Fatalf("InitSession: %v", err)
}
sess := srv.Sessions()[0]
req, _ := http.NewRequest("GET", srv.URL+"/apirest.php/getMyProfiles", nil)
req.Header.Set("App-Token", appTok)
req.Header.Set("Session-Token", sess)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("raw GET: %v", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var probe struct {
MyProfiles []struct {
ID int `json:"id"`
Name string `json:"name"`
Entities []any `json:"entities"` // optional in the real API
} `json:"myprofiles"`
}
if err := json.Unmarshal(b, &probe); err != nil {
t.Fatalf("getMyProfiles body is not the wrapped shape: %s", b)
}
if len(probe.MyProfiles) != 3 || probe.MyProfiles[2].Name != "Super-admin" {
t.Errorf("wrapped rows = %+v", probe.MyProfiles)
}
// ...and the active-profile endpoint answers the wrapped object too.
req2, _ := http.NewRequest("GET", srv.URL+"/apirest.php/getActiveProfile", nil)
req2.Header.Set("App-Token", appTok)
req2.Header.Set("Session-Token", sess)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("raw GET active: %v", err)
}
defer resp2.Body.Close()
b2, _ := io.ReadAll(resp2.Body)
var probe2 struct {
ActiveProfile struct {
ID int `json:"id"`
} `json:"active_profile"`
}
if err := json.Unmarshal(b2, &probe2); err != nil || probe2.ActiveProfile.ID != 6 {
t.Fatalf("getActiveProfile body = %s (%v), want wrapped active_profile id 6", b2, 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: `<<not json>>`}
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")
}
}