diff --git a/README.md b/README.md index 566c58e..1f4c103 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ Flags may appear before or after positionals (`mglpi change show 104 | environment | `MGLPI_URL`, `MGLPI_APP_TOKEN`, `MGLPI_USER_TOKEN`, `MGLPI_PROFILE_ID` (optional) | env wins over file | | `--config PATH` (also `$MGLPI_CONFIG`, default `~/.config/mglpi/env`) | same keys | file must be 0600 or stricter; refused before any read; parsed in pure Go (no sourcing/expansion) | +`MGLPI_URL` is the FULL API base INCLUDING `/apirest.php` — e.g. +`https://cmdb.knownelement.com/apirest.php`, not just the server host. + Tokens travel only in headers: `App-Token` on every request, `Authorization: user_token ...` on initSession, `Session-Token` after. Response bodies are never surfaced in error strings — the fake GLPI diff --git a/env.example b/env.example index 738aff9..d756739 100644 --- a/env.example +++ b/env.example @@ -1,8 +1,10 @@ # mglpi connection env file (copy to ~/.creds/mglpi.env and chmod 600) -# MGLPI_URL, MGLPI_APP_TOKEN and MGLPI_USER_TOKEN are required; -# MGLPI_PROFILE_ID is optional (agent mode: auto-switch to this GLPI -# profile id, e.g. 5 = Hotliner, after InitSession). The file must be -# 0600 or stricter — mglpi refuses looser files before reading them. +# MGLPI_URL is the FULL API base INCLUDING /apirest.php — e.g. +# https://cmdb.knownelement.com/apirest.php +# (not just the server host). MGLPI_APP_TOKEN and MGLPI_USER_TOKEN are +# required; MGLPI_PROFILE_ID is optional (agent mode: auto-switch to +# this GLPI profile id, e.g. 5 = Hotliner, after InitSession). The file +# must be 0600 or stricter — mglpi refuses looser files before reading. MGLPI_URL=https://cmdb.knownelement.com/apirest.php MGLPI_APP_TOKEN=your-glpi-app-token MGLPI_USER_TOKEN=your-glpi-user-token diff --git a/glpi/session.go b/glpi/session.go index 0f2c6eb..9846c84 100644 --- a/glpi/session.go +++ b/glpi/session.go @@ -7,21 +7,44 @@ import ( ) // Profile is one GLPI profile of the logged-in user (GET /getMyProfiles, -// GET /Profile/). IsActive reflects the session's active profile. +// GET /Profile/). The myprofiles rows carry NO is_active on the +// real wire — the active profile is detected via GetActiveProfile. type Profile struct { ID int `json:"id"` Name string `json:"name"` IsActive bool `json:"is_active"` } -// GetMyProfiles lists the profiles of the logged-in user. GLPI answers -// with a bare JSON array (not a wrapped object). +// GetMyProfiles lists the profiles of the logged-in user. GLPI's REAL +// wire shape (verified against prod cmdb 2026-09-03) is a WRAPPED +// object — {"myprofiles":[{"id":N,"name":"...","entities":[...]},...]} — +// NOT a bare array; rows may carry extra fields (entities, comments) +// which are ignored here. IsActive is left false by this call. func (c *Client) GetMyProfiles(ctx context.Context) ([]Profile, error) { - var profiles []Profile - if err := c.call(ctx, http.MethodGet, "/getMyProfiles", nil, nil, &profiles); err != nil { + var body struct { + MyProfiles []Profile `json:"myprofiles"` + } + if err := c.call(ctx, http.MethodGet, "/getMyProfiles", nil, nil, &body); err != nil { return nil, err } - return profiles, nil + return body.MyProfiles, nil +} + +// GetActiveProfile returns the id of the session's active profile +// (GET /getActiveProfile → {"active_profile":{...,"id":N,...}}). +func (c *Client) GetActiveProfile(ctx context.Context) (int, error) { + var body struct { + ActiveProfile struct { + ID int `json:"id"` + } `json:"active_profile"` + } + if err := c.call(ctx, http.MethodGet, "/getActiveProfile", nil, nil, &body); err != nil { + return 0, err + } + if body.ActiveProfile.ID == 0 { + return 0, fmt.Errorf("%w: getActiveProfile returned no id", ErrMalformedResponse) + } + return body.ActiveProfile.ID, nil } // ChangeActiveProfile switches the session's active profile diff --git a/glpi_test.go b/glpi_test.go index aa379c1..624b5df 100644 --- a/glpi_test.go +++ b/glpi_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strconv" "strings" @@ -361,14 +362,21 @@ func TestProfiles(t *testing.T) { if len(profs) != 3 { t.Fatalf("profiles = %+v, want 3", profs) } - active := 0 - for _, p := range profs { - if p.IsActive { - active = p.ID + 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) } } - if active != 6 { // Super-admin is the fake's default active profile - t.Errorf("default active profile = %d, want 6", active) + // 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 { @@ -377,20 +385,68 @@ func TestProfiles(t *testing.T) { 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 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. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5f91675..de88abf 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "net/http" "os" "path/filepath" "strconv" @@ -341,6 +342,45 @@ func TestProfileSwitchAgentMode(t *testing.T) { } } +// Real GLPI exposes the active profile only via GET /getActiveProfile. +// whoami must detect it there — and DEGRADE to all-inactive (not fail) +// when that endpoint is missing or permission-blocked. +func TestWhoamiActiveProfileDegradation(t *testing.T) { + tests := []struct { + name string + brk int + wantAct bool // [active] marker expected? + }{ + {"working endpoint marks active", 0, true}, + {"403 degrades to all-inactive", http.StatusForbidden, false}, + {"404 degrades to all-inactive", http.StatusNotFound, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := boot(t) + srv.BreakActiveProfile = tt.brk + out, errb, code := run(t, "whoami") + if code != 0 { + t.Fatalf("code = %d, stderr = %q, want 0", code, errb) + } + if !strings.Contains(out, "Hotliner") || !strings.Contains(out, "Super-admin") { + t.Errorf("profiles missing from output: %q", out) + } + if got := strings.Contains(out, "[active]"); got != tt.wantAct { + t.Errorf("[active] marker = %v, want %v (out %q)", got, tt.wantAct, out) + } + }) + } + t.Run("500 on getActiveProfile still errors", func(t *testing.T) { + srv := boot(t) + srv.BreakActiveProfile = http.StatusInternalServerError + _, _, code := run(t, "whoami") + if code != 2 { + t.Fatalf("code = %d, want 2 (unexpected server errors are surfaced)", code) + } + }) +} + func TestAPIErrorIsExitTwo(t *testing.T) { srv := boot(t) srv.Fail = &fakeglpi.FailSpec{Status: 404, Body: `[{"ERROR_ITEM_NOT_FOUND":"gone %s"}]`} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index a1619ad..ab9cea9 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -19,6 +20,10 @@ func marshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " // cmdWhoami lists the session's profiles. It NEVER switches the active // profile (agent mode's MGLPI_PROFILE_ID is deliberately not applied). +// Active-profile detection comes from GetActiveProfile; when that +// endpoint is missing or permission-blocked (404/403), whoami degrades +// gracefully to all-inactive rather than erroring — other failures +// (5xx, unreachable) still surface as API errors. func cmdWhoami(args []string, stdout, stderr io.Writer) int { fs, cfgPath, out := newFlags("whoami", stderr) pos, err := parseArgs(fs, args) @@ -33,6 +38,16 @@ func cmdWhoami(args []string, stdout, stderr io.Writer) int { if err != nil { return apiErr(stderr, err) } + activeID, aerr := c.GetActiveProfile(context.Background()) + degraded := aerr != nil && (errors.Is(aerr, glpi.ErrNotFound) || errors.Is(aerr, glpi.ErrAuth)) + if aerr != nil && !degraded { + return apiErr(stderr, aerr) + } + if !degraded { + for i := range profs { + profs[i].IsActive = profs[i].ID == activeID + } + } if *out == "json" { emitJSON(stdout, map[string]any{"profiles": profs}) return 0 diff --git a/internal/fakeglpi/fakeglpi.go b/internal/fakeglpi/fakeglpi.go index cf13000..2962203 100644 --- a/internal/fakeglpi/fakeglpi.go +++ b/internal/fakeglpi/fakeglpi.go @@ -116,6 +116,10 @@ type Server struct { // Fail, when non-nil, is returned instead of normal handling; it is // consumed by the first request that sees it. Fail *FailSpec + // BreakActiveProfile, when non-zero, makes GET /getActiveProfile + // answer that error status (degradation tests: 403/404 degrade, + // 500 surfaces). + BreakActiveProfile int mu sync.Mutex srv *httptest.Server @@ -355,6 +359,8 @@ func (s *Server) handler(w http.ResponseWriter, r *http.Request) { respBody = `true` case r.Method == http.MethodGet && path == "/getMyProfiles": respStatus, respBody = s.getMyProfiles(tok) + case r.Method == http.MethodGet && path == "/getActiveProfile": + respStatus, respBody = s.getActiveProfile(tok) case r.Method == http.MethodPost && path == "/changeActiveProfile": respStatus, respBody = s.changeActiveProfile(tok, body) case r.Method == http.MethodGet && strings.HasPrefix(path, "/Profile/"): @@ -413,12 +419,37 @@ func (s *Server) initSession(r *http.Request) (int, string) { } 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] + // GLPI's REAL wire shape (verified against prod cmdb 2026-09-03): + // WRAPPED object {"myprofiles":[...]}; rows carry id/name/entities + // and NO is_active — the active profile is GET /getActiveProfile. + rows := make([]map[string]any, 0, len(s.profiles)) + for _, p := range s.profiles { + rows = append(rows, map[string]any{"id": p.ID, "name": p.Name, "entities": []any{}}) } - return jsonReply(out) + return jsonReply(map[string]any{"myprofiles": rows}) +} + +// getActiveProfile answers GLPI's {"active_profile":{...,"id":N,...}}. +func (s *Server) getActiveProfile(tok string) (int, string) { + if s.BreakActiveProfile != 0 { + switch s.BreakActiveProfile { + case http.StatusForbidden: + return http.StatusForbidden, `[{"ERROR_RIGHT_MISSING":true}]` + case http.StatusNotFound: + return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` + default: + return s.BreakActiveProfile, `[{"ERROR_GLPI":true}]` + } + } + id := s.activeProfile[tok] + for _, p := range s.profiles { + if p.ID == id { + return jsonReply(map[string]any{ + "active_profile": map[string]any{"id": p.ID, "name": p.Name, "entities": []any{}}, + }) + } + } + return http.StatusNotFound, `[{"ERROR_ITEM_NOT_FOUND":true}]` } func (s *Server) changeActiveProfile(tok string, body []byte) (int, string) {