feat: v0 — glpi-go client, mglpi CLI, mglpi-mcp, fake-GLPI test suite [#767]
https://projects.knownelement.com/issues/767#note-4191
This commit is contained in:
+530
@@ -0,0 +1,530 @@
|
||||
// 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", "<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)
|
||||
}
|
||||
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: `<<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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user