fix(session): real getMyProfiles shape {myprofiles:[...]} + active-profile detection [#767]
https://projects.knownelement.com/issues/767#note-4191
This commit is contained in:
@@ -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 |
|
| 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) |
|
| `--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,
|
Tokens travel only in headers: `App-Token` on every request,
|
||||||
`Authorization: user_token ...` on initSession, `Session-Token` after.
|
`Authorization: user_token ...` on initSession, `Session-Token` after.
|
||||||
Response bodies are never surfaced in error strings — the fake GLPI
|
Response bodies are never surfaced in error strings — the fake GLPI
|
||||||
|
|||||||
+6
-4
@@ -1,8 +1,10 @@
|
|||||||
# mglpi connection env file (copy to ~/.creds/mglpi.env and chmod 600)
|
# mglpi connection env file (copy to ~/.creds/mglpi.env and chmod 600)
|
||||||
# MGLPI_URL, MGLPI_APP_TOKEN and MGLPI_USER_TOKEN are required;
|
# MGLPI_URL is the FULL API base INCLUDING /apirest.php — e.g.
|
||||||
# MGLPI_PROFILE_ID is optional (agent mode: auto-switch to this GLPI
|
# https://cmdb.knownelement.com/apirest.php
|
||||||
# profile id, e.g. 5 = Hotliner, after InitSession). The file must be
|
# (not just the server host). MGLPI_APP_TOKEN and MGLPI_USER_TOKEN are
|
||||||
# 0600 or stricter — mglpi refuses looser files before reading them.
|
# 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_URL=https://cmdb.knownelement.com/apirest.php
|
||||||
MGLPI_APP_TOKEN=your-glpi-app-token
|
MGLPI_APP_TOKEN=your-glpi-app-token
|
||||||
MGLPI_USER_TOKEN=your-glpi-user-token
|
MGLPI_USER_TOKEN=your-glpi-user-token
|
||||||
|
|||||||
+29
-6
@@ -7,21 +7,44 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Profile is one GLPI profile of the logged-in user (GET /getMyProfiles,
|
// Profile is one GLPI profile of the logged-in user (GET /getMyProfiles,
|
||||||
// GET /Profile/<id>). IsActive reflects the session's active profile.
|
// GET /Profile/<id>). The myprofiles rows carry NO is_active on the
|
||||||
|
// real wire — the active profile is detected via GetActiveProfile.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMyProfiles lists the profiles of the logged-in user. GLPI answers
|
// GetMyProfiles lists the profiles of the logged-in user. GLPI's REAL
|
||||||
// with a bare JSON array (not a wrapped object).
|
// 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) {
|
func (c *Client) GetMyProfiles(ctx context.Context) ([]Profile, error) {
|
||||||
var profiles []Profile
|
var body struct {
|
||||||
if err := c.call(ctx, http.MethodGet, "/getMyProfiles", nil, nil, &profiles); err != nil {
|
MyProfiles []Profile `json:"myprofiles"`
|
||||||
|
}
|
||||||
|
if err := c.call(ctx, http.MethodGet, "/getMyProfiles", nil, nil, &body); err != nil {
|
||||||
return nil, err
|
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
|
// ChangeActiveProfile switches the session's active profile
|
||||||
|
|||||||
+70
-14
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -361,14 +362,21 @@ func TestProfiles(t *testing.T) {
|
|||||||
if len(profs) != 3 {
|
if len(profs) != 3 {
|
||||||
t.Fatalf("profiles = %+v, want 3", profs)
|
t.Fatalf("profiles = %+v, want 3", profs)
|
||||||
}
|
}
|
||||||
active := 0
|
for _, want := range []string{"Self-Service", "Hotliner", "Super-admin"} {
|
||||||
for _, p := range profs {
|
found := false
|
||||||
if p.IsActive {
|
for _, p := range profs {
|
||||||
active = p.ID
|
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
|
// The active profile comes from GET /getActiveProfile, not from the
|
||||||
t.Errorf("default active profile = %d, want 6", active)
|
// 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 {
|
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}` {
|
if body := lastReq(t, srv).Body; body != `{"profiles_id":5}` {
|
||||||
t.Errorf("changeActiveProfile body = %s", body)
|
t.Errorf("changeActiveProfile body = %s", body)
|
||||||
}
|
}
|
||||||
profs, _ = c.GetMyProfiles(context.Background())
|
if active, err := c.GetActiveProfile(context.Background()); err != nil || active != 5 {
|
||||||
for _, p := range profs {
|
t.Errorf("GetActiveProfile after switch = %d, %v; want 5 (Hotliner)", active, err)
|
||||||
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) {
|
if err := c.ChangeActiveProfile(context.Background(), 999); !errors.Is(err, glpi.ErrValidation) {
|
||||||
t.Errorf("unknown profile err = %v, want ErrValidation", err)
|
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
|
// THE agent-mode proof: with RequireChangeProfile=5 the fake rejects
|
||||||
// change creation until the session's active profile IS 5 — the exact
|
// change creation until the session's active profile IS 5 — the exact
|
||||||
// Hotliner flow an agent env file drives via MGLPI_PROFILE_ID.
|
// Hotliner flow an agent env file drives via MGLPI_PROFILE_ID.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"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) {
|
func TestAPIErrorIsExitTwo(t *testing.T) {
|
||||||
srv := boot(t)
|
srv := boot(t)
|
||||||
srv.Fail = &fakeglpi.FailSpec{Status: 404, Body: `[{"ERROR_ITEM_NOT_FOUND":"gone %s"}]`}
|
srv.Fail = &fakeglpi.FailSpec{Status: 404, Body: `[{"ERROR_ITEM_NOT_FOUND":"gone %s"}]`}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"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
|
// cmdWhoami lists the session's profiles. It NEVER switches the active
|
||||||
// profile (agent mode's MGLPI_PROFILE_ID is deliberately not applied).
|
// 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 {
|
func cmdWhoami(args []string, stdout, stderr io.Writer) int {
|
||||||
fs, cfgPath, out := newFlags("whoami", stderr)
|
fs, cfgPath, out := newFlags("whoami", stderr)
|
||||||
pos, err := parseArgs(fs, args)
|
pos, err := parseArgs(fs, args)
|
||||||
@@ -33,6 +38,16 @@ func cmdWhoami(args []string, stdout, stderr io.Writer) int {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return apiErr(stderr, err)
|
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" {
|
if *out == "json" {
|
||||||
emitJSON(stdout, map[string]any{"profiles": profs})
|
emitJSON(stdout, map[string]any{"profiles": profs})
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ type Server struct {
|
|||||||
// Fail, when non-nil, is returned instead of normal handling; it is
|
// Fail, when non-nil, is returned instead of normal handling; it is
|
||||||
// consumed by the first request that sees it.
|
// consumed by the first request that sees it.
|
||||||
Fail *FailSpec
|
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
|
mu sync.Mutex
|
||||||
srv *httptest.Server
|
srv *httptest.Server
|
||||||
@@ -355,6 +359,8 @@ func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
|
|||||||
respBody = `true`
|
respBody = `true`
|
||||||
case r.Method == http.MethodGet && path == "/getMyProfiles":
|
case r.Method == http.MethodGet && path == "/getMyProfiles":
|
||||||
respStatus, respBody = s.getMyProfiles(tok)
|
respStatus, respBody = s.getMyProfiles(tok)
|
||||||
|
case r.Method == http.MethodGet && path == "/getActiveProfile":
|
||||||
|
respStatus, respBody = s.getActiveProfile(tok)
|
||||||
case r.Method == http.MethodPost && path == "/changeActiveProfile":
|
case r.Method == http.MethodPost && path == "/changeActiveProfile":
|
||||||
respStatus, respBody = s.changeActiveProfile(tok, body)
|
respStatus, respBody = s.changeActiveProfile(tok, body)
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/Profile/"):
|
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) {
|
func (s *Server) getMyProfiles(tok string) (int, string) {
|
||||||
out := make([]Profile, len(s.profiles))
|
// GLPI's REAL wire shape (verified against prod cmdb 2026-09-03):
|
||||||
copy(out, s.profiles)
|
// WRAPPED object {"myprofiles":[...]}; rows carry id/name/entities
|
||||||
for i := range out {
|
// and NO is_active — the active profile is GET /getActiveProfile.
|
||||||
out[i].IsActive = out[i].ID == s.activeProfile[tok]
|
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) {
|
func (s *Server) changeActiveProfile(tok string, body []byte) (int, string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user