From 9b8f53d0d0ea7c01879a14442f88aebf0f6eb4be Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 14 Oct 2026 10:12:41 +0200 Subject: [PATCH] =?UTF-8?q?MCP=20:=20outils,=20normalisation=20des=20?= =?UTF-8?q?=C3=A9l=C3=A9ments=20et=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Nh6YQSjM9ni5jiTc8u5Tj1 --- internal/mcp/mcp_test.go | 210 +++++++++++++++++++ internal/mcp/scene.go | 343 +++++++++++++++++++++++++++++++ internal/mcp/tools.go | 360 +++++++++++++++++++++++++++++++++ internal/tokens/tokens_test.go | 288 ++++++++++++++++++++++++++ 4 files changed, 1201 insertions(+) create mode 100644 internal/mcp/mcp_test.go create mode 100644 internal/mcp/scene.go create mode 100644 internal/mcp/tools.go create mode 100644 internal/tokens/tokens_test.go diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..d8f363b --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,210 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vblogio/excalidraw-go/internal/storage" +) + +func newServer(t *testing.T) http.Handler { + t.Helper() + st, err := storage.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return &Server{ + Spaces: storage.NewSpaces(st), + User: func(*http.Request) string { return "a@x.fr" }, + BaseURL: func(*http.Request) string { return "https://ex.test" }, + MaxBody: 1 << 20, + Version: "test", + } +} + +func rpc(t *testing.T, h http.Handler, method string, params any) map[string]any { + t.Helper() + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/mcp", strings.NewReader(string(b)))) + var res map[string]any + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &res) != nil { + t.Fatalf("%s : %d %s", method, rec.Code, rec.Body) + } + return res +} + +// tool appelle un outil et renvoie son résultat structuré (ou son erreur). +func callTool(t *testing.T, h http.Handler, name string, args any) (map[string]any, string) { + t.Helper() + res := rpc(t, h, "tools/call", map[string]any{"name": name, "arguments": args}) + r, _ := res["result"].(map[string]any) + if r == nil { + t.Fatalf("%s : %v", name, res) + } + if r["isError"] == true { + return nil, r["content"].([]any)[0].(map[string]any)["text"].(string) + } + return r["structuredContent"].(map[string]any), "" +} + +func TestProtocol(t *testing.T) { + h := newServer(t) + res := rpc(t, h, "initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}}) + r := res["result"].(map[string]any) + if r["protocolVersion"] != "2025-06-18" || r["capabilities"].(map[string]any)["tools"] == nil { + t.Fatalf("initialize = %v", r) + } + if v := rpc(t, h, "initialize", map[string]any{"protocolVersion": "1999-01-01"})["result"].(map[string]any)["protocolVersion"]; v != protocolVersions[0] { + t.Fatalf("version inconnue → %v", v) + } + tl := rpc(t, h, "tools/list", nil)["result"].(map[string]any)["tools"].([]any) + if len(tl) != len(handlers) { + t.Fatalf("%d outils", len(tl)) + } + if e := rpc(t, h, "nope", nil)["error"].(map[string]any); e["code"].(float64) != errMethod { + t.Fatalf("méthode inconnue : %v", e) + } + // Notification : 202 sans corps ; GET : 405. + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))) + if rec.Code != 202 || rec.Body.Len() != 0 { + t.Fatalf("notification : %d", rec.Code) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/mcp", nil)) + if rec.Code != 405 { + t.Fatalf("GET : %d", rec.Code) + } +} + +func TestWriteReadListSearch(t *testing.T) { + h := newServer(t) + els := []any{ + map[string]any{"id": "web", "type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 80, "label": map[string]any{"text": "Web T1"}}, + map[string]any{"id": "db", "type": "ellipse", "x": 400, "y": 0, "width": 160, "height": 80, "label": map[string]any{"text": "Base"}}, + map[string]any{"id": "link", "type": "arrow", "start": map[string]any{"id": "web"}, "end": map[string]any{"id": "db"}, "label": map[string]any{"text": "TCP 5432"}}, + map[string]any{"type": "text", "x": 0, "y": 200, "text": "Segment overlay 172.27.207.0/24"}, + } + out, errMsg := callTool(t, h, "write_diagram", map[string]any{"path": "réseau/app trois tiers", "elements": els}) + if errMsg != "" || out["path"] != "réseau/app trois tiers.excalidraw" || out["created"] != true { + t.Fatalf("write = %v %s", out, errMsg) + } + if out["url"] != "https://ex.test/#/r%C3%A9seau/app%20trois%20tiers.excalidraw" { + t.Fatalf("url = %v", out["url"]) + } + if _, errMsg := callTool(t, h, "write_diagram", map[string]any{"path": "réseau/app trois tiers.excalidraw", "elements": []any{}}); !strings.Contains(errMsg, "existe déjà") { + t.Fatalf("écrasement sans overwrite : %q", errMsg) + } + + got, _ := callTool(t, h, "read_diagram", map[string]any{"path": "réseau/app trois tiers.excalidraw"}) + byID := map[string]map[string]any{} + for _, e := range got["elements"].([]any) { + m := e.(map[string]any) + byID[m["id"].(string)] = m + } + if len(byID) != 7 { + t.Fatalf("%d éléments", len(byID)) + } + lbl := byID["web-label"] + if lbl["containerId"] != "web" || lbl["text"] != "Web T1" || lbl["verticalAlign"] != "middle" { + t.Fatalf("label = %v", lbl) + } + arrow := byID["link"] + if arrow["startBinding"].(map[string]any)["elementId"] != "web" || arrow["endBinding"].(map[string]any)["elementId"] != "db" { + t.Fatalf("liaisons = %v", arrow) + } + // La flèche part du bord droit du rectangle et arrive au bord gauche de l'ellipse. + pts := arrow["points"].([]any) + if x := arrow["x"].(float64); x != 204 || pts[1].([]any)[0].(float64) != 400-4-204 { + t.Fatalf("géométrie = x %v points %v", x, pts) + } + bound := byID["db"]["boundElements"].([]any) + if len(bound) != 2 { + t.Fatalf("boundElements de db = %v", bound) + } + for _, k := range []string{"seed", "version", "strokeColor", "roughness"} { + if _, ok := byID["web"][k]; !ok { + t.Errorf("champ %s non complété", k) + } + } + + l, _ := callTool(t, h, "list_diagrams", map[string]any{"recursive": true}) + paths := []string{} + for _, e := range l["entries"].([]any) { + paths = append(paths, e.(map[string]any)["path"].(string)) + } + if strings.Join(paths, ",") != ".favoris,.perso,réseau,réseau/app trois tiers.excalidraw" { + t.Fatalf("liste = %v", paths) + } + s, _ := callTool(t, h, "search_diagrams", map[string]any{"query": "172.27.207"}) + if r := s["results"].([]any); len(r) != 1 || !strings.Contains(r[0].(map[string]any)["snippet"].(string), "overlay") { + t.Fatalf("recherche = %v", s) + } +} + +func TestOverwriteKeepsFilesAndPerso(t *testing.T) { + h := newServer(t) + img := map[string]any{"id": "img", "type": "image", "fileId": "f1", "x": 0, "y": 0, "width": 10, "height": 10} + files := map[string]any{"f1": map[string]any{"id": "f1", "mimeType": "image/png", "dataURL": "data:image/png;base64,AA"}} + if _, e := callTool(t, h, "write_diagram", map[string]any{"path": ".perso/logo", "elements": []any{img}, "files": files}); e != "" { + t.Fatal(e) + } + r, _ := callTool(t, h, "read_diagram", map[string]any{"path": ".perso/logo.excalidraw"}) + if r["files"] != nil || r["filesOmitted"].(float64) != 1 { + t.Fatalf("files non omis : %v", r) + } + // Réécriture sans files : l'image référencée est conservée. + els := append(r["elements"].([]any), map[string]any{"type": "text", "text": "ajout"}) + out, e := callTool(t, h, "write_diagram", map[string]any{"path": ".perso/logo.excalidraw", "elements": els, "overwrite": true}) + if e != "" || out["created"] != false { + t.Fatalf("overwrite : %v %s", out, e) + } + r, _ = callTool(t, h, "read_diagram", map[string]any{"path": ".perso/logo.excalidraw", "include_files": true}) + if r["files"].(map[string]any)["f1"] == nil || len(r["elements"].([]any)) != 2 { + t.Fatalf("après overwrite : %v", r) + } + if f, _ := callTool(t, h, "create_folder", map[string]any{"path": ".perso/a/b"}); f["created"] != true { + t.Fatalf("dossier : %v", f) + } + if f, _ := callTool(t, h, "create_folder", map[string]any{"path": ".perso/a/b"}); f["created"] != false { + t.Fatalf("dossier existant : %v", f) + } +} + +func TestToolErrors(t *testing.T) { + h := newServer(t) + cases := []struct { + name string + args any + want string + }{ + {"read_diagram", map[string]any{"path": "absent.excalidraw"}, "introuvable"}, + {"read_diagram", map[string]any{"path": "x", "bogus": 1}, "argument invalide"}, + {"write_diagram", map[string]any{"path": "x", "elements": []any{map[string]any{"x": 1}}}, "type manquant"}, + {"write_diagram", map[string]any{"path": "x", "elements": []any{map[string]any{"type": "arrow", "start": map[string]any{"id": "nope"}}}}, "inconnu"}, + {"write_diagram", map[string]any{"path": ".users/x", "elements": []any{}}, "chemin"}, + {"search_diagrams", map[string]any{}, "requis"}, + } + for _, c := range cases { + if _, e := callTool(t, h, c.name, c.args); !strings.Contains(strings.ToLower(e), c.want) { + t.Errorf("%s %v : %q, attendu %q", c.name, c.args, e, c.want) + } + } +} + +func TestFitLabel(t *testing.T) { + els, err := normalize([]any{map[string]any{"id": "d", "type": "diamond", "x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0, + "label": map[string]any{"text": "NSX Edge cluster"}}}) + if err != nil { + t.Fatal(err) + } + d := els[0].(map[string]any) + w, x := d["width"].(float64), d["x"].(float64) + if w <= 100 || x+w/2 != 50 || d["y"].(float64)+d["height"].(float64)/2 != 50 { + t.Fatalf("losange non agrandi autour de son centre : %v", d) + } +} diff --git a/internal/mcp/scene.go b/internal/mcp/scene.go new file mode 100644 index 0000000..16555b8 --- /dev/null +++ b/internal/mcp/scene.go @@ -0,0 +1,343 @@ +package mcp + +import ( + "crypto/rand" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "math" + "strings" + "time" + "unicode/utf8" +) + +// Normalisation des éléments écrits par un client MCP : un modèle produit +// rarement tous les champs d'un élément Excalidraw. normalize complète les +// valeurs par défaut et accepte deux raccourcis de l'API « skeleton » +// d'Excalidraw : +// - "label": {"text": "..."} sur un rectangle, une ellipse, un losange ou +// une flèche : texte lié, centré dans le conteneur ; +// - "start"/"end": {"id": "..."} sur une flèche : liaison aux formes, +// points calculés entre leurs bords si la flèche n'en donne pas. +// +// Excalidraw répare encore la scène au chargement (restore), mais un fichier +// complet s'affiche aussi correctement en miniature et dans la recherche. + +type element = map[string]any + +func randID() string { + b := make([]byte, 15) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func randInt() int64 { + var b [4]byte + _, _ = rand.Read(b[:]) + return int64(binary.BigEndian.Uint32(b[:]) >> 1) +} + +func num(e element, k string) (float64, bool) { + switch v := e[k].(type) { + case float64: + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + } + return 0, false +} + +func numOr(e element, k string, def float64) float64 { + if v, ok := num(e, k); ok { + return v + } + return def +} + +func setDefault(e element, k string, v any) { + if _, ok := e[k]; !ok { + e[k] = v + } +} + +// textSize estime l'encombrement d'un texte (Excalidraw le recalcule à l'affichage). +func textSize(text string, fontSize, lineHeight float64) (w, h float64) { + lines := strings.Split(text, "\n") + longest := 0 + for _, l := range lines { + longest = max(longest, utf8.RuneCountInString(l)) + } + return math.Ceil(float64(longest) * fontSize * 0.6), math.Ceil(float64(len(lines)) * fontSize * lineHeight) +} + +var containers = map[string]bool{"rectangle": true, "ellipse": true, "diamond": true, "arrow": true} + +// normalize valide et complète les éléments ; ils sont modifiés en place. +func normalize(els []any) ([]any, error) { + now := time.Now().UnixMilli() + byID := map[string]element{} + var out []any + var labels, arrows []element + for i, raw := range els { + e, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("élément %d : objet attendu", i) + } + typ, _ := e["type"].(string) + if typ == "" { + return nil, fmt.Errorf("élément %d : champ type manquant", i) + } + if id, _ := e["id"].(string); id == "" { + e["id"] = randID() + } + if _, dup := byID[e["id"].(string)]; dup { + return nil, fmt.Errorf("élément %d : id %q en double", i, e["id"]) + } + byID[e["id"].(string)] = e + base(e, now) + switch typ { + case "text": + if err := text(e); err != nil { + return nil, fmt.Errorf("élément %d : %w", i, err) + } + case "arrow", "line": + if typ == "arrow" { + setDefault(e, "roundness", map[string]any{"type": 2}) + } + arrows = append(arrows, e) + case "rectangle", "diamond": + setDefault(e, "roundness", map[string]any{"type": 3}) + } + setDefault(e, "roundness", nil) + if _, ok := e["label"]; ok && containers[typ] { + labels = append(labels, e) + } + out = append(out, e) + } + for _, c := range labels { + fit(c) + } + for _, a := range arrows { + if err := bindArrow(a, byID); err != nil { + return nil, err + } + linear(a) + } + for _, c := range labels { + t, err := label(c, byID) + if err != nil { + return nil, err + } + base(t, now) + out = append(out, t) + } + return out, nil +} + +func base(e element, now int64) { + setDefault(e, "x", 0.0) + setDefault(e, "y", 0.0) + if e["type"] != "text" && e["type"] != "arrow" && e["type"] != "line" { + setDefault(e, "width", 100.0) + setDefault(e, "height", 100.0) + } + setDefault(e, "angle", 0) + setDefault(e, "strokeColor", "#1e1e1e") + setDefault(e, "backgroundColor", "transparent") + setDefault(e, "fillStyle", "solid") + setDefault(e, "strokeWidth", 2) + setDefault(e, "strokeStyle", "solid") + setDefault(e, "roughness", 1) + setDefault(e, "opacity", 100) + setDefault(e, "groupIds", []any{}) + setDefault(e, "frameId", nil) + setDefault(e, "boundElements", nil) + setDefault(e, "link", nil) + setDefault(e, "locked", false) + setDefault(e, "isDeleted", false) + setDefault(e, "seed", randInt()) + setDefault(e, "version", 1) + setDefault(e, "versionNonce", randInt()) + setDefault(e, "updated", now) +} + +func text(e element) error { + s, ok := e["text"].(string) + if !ok { + return errors.New("texte sans champ text") + } + setDefault(e, "originalText", s) + setDefault(e, "fontSize", 20) + setDefault(e, "fontFamily", 5) // Excalifont + setDefault(e, "textAlign", "left") + setDefault(e, "verticalAlign", "top") + setDefault(e, "containerId", nil) + setDefault(e, "autoResize", true) + setDefault(e, "lineHeight", 1.25) + w, h := textSize(s, numOr(e, "fontSize", 20), numOr(e, "lineHeight", 1.25)) + setDefault(e, "width", w) + setDefault(e, "height", h) + return nil +} + +func addBound(c element, id, typ string) { + list, _ := c["boundElements"].([]any) + for _, b := range list { + if m, ok := b.(map[string]any); ok && m["id"] == id { + return + } + } + c["boundElements"] = append(list, map[string]any{"id": id, "type": typ}) +} + +// label transforme le raccourci "label" d'un conteneur en élément texte lié. +func label(c element, byID map[string]element) (element, error) { + l, _ := c["label"].(map[string]any) + delete(c, "label") + s, _ := l["text"].(string) + if s == "" { + return nil, fmt.Errorf("élément %v : label sans texte", c["id"]) + } + id := c["id"].(string) + "-label" + if _, dup := byID[id]; dup { + id = randID() + } + fontSize := numOr(l, "fontSize", 20) + w, h := textSize(s, fontSize, 1.25) + cx, cy := center(c) + t := element{ + "id": id, "type": "text", "text": s, "originalText": s, + "fontSize": fontSize, "fontFamily": numOr(l, "fontFamily", 5), + "textAlign": "center", "verticalAlign": "middle", "containerId": c["id"], + "autoResize": true, "lineHeight": 1.25, "roundness": nil, + "x": cx - w/2, "y": cy - h/2, "width": w, "height": h, + "strokeColor": c["strokeColor"], + } + if col, ok := l["strokeColor"].(string); ok { + t["strokeColor"] = col + } + addBound(c, id, "text") + byID[id] = t + return t, nil +} + +// fit agrandit une forme (centre conservé) pour que son label y tienne. +func fit(c element) { + l, _ := c["label"].(map[string]any) + s, _ := l["text"].(string) + if s == "" || c["type"] == "arrow" { + return + } + tw, th := textSize(s, numOr(l, "fontSize", 20), 1.25) + k := 1.0 // part utilisable de la forme pour le texte + switch c["type"] { + case "ellipse": + k = 1 / math.Sqrt2 + case "diamond": + k = 0.5 + } + w, h := numOr(c, "width", 100), numOr(c, "height", 100) + nw, nh := math.Max(w, math.Ceil((tw+20)/k)), math.Max(h, math.Ceil((th+20)/k)) + c["x"] = numOr(c, "x", 0) - (nw-w)/2 + c["y"] = numOr(c, "y", 0) - (nh-h)/2 + c["width"], c["height"] = nw, nh +} + +func center(e element) (float64, float64) { + x, y := numOr(e, "x", 0), numOr(e, "y", 0) + if pts, ok := e["points"].([]any); ok && len(pts) >= 2 { + // Milieu du segment central d'une flèche. + i := len(pts) / 2 + a, b := point(pts[i-1]), point(pts[i]) + return x + (a[0]+b[0])/2, y + (a[1]+b[1])/2 + } + return x + numOr(e, "width", 0)/2, y + numOr(e, "height", 0)/2 +} + +func point(v any) [2]float64 { + p, _ := v.([]any) + var out [2]float64 + for i := 0; i < 2 && i < len(p); i++ { + out[i], _ = p[i].(float64) + } + return out +} + +// edge renvoie le point du bord de la forme e dans la direction de (tx, ty). +func edge(e element, tx, ty, gap float64) (float64, float64) { + cx, cy := center(e) + dx, dy := tx-cx, ty-cy + if dx == 0 && dy == 0 { + return cx, cy + } + hw, hh := numOr(e, "width", 0)/2+gap, numOr(e, "height", 0)/2+gap + var t float64 + switch e["type"] { + case "ellipse": + t = 1 / math.Sqrt(dx*dx/(hw*hw)+dy*dy/(hh*hh)) + case "diamond": + t = 1 / (math.Abs(dx)/hw + math.Abs(dy)/hh) + default: + t = math.Min(hw/math.Max(math.Abs(dx), 1e-9), hh/math.Max(math.Abs(dy), 1e-9)) + } + return cx + dx*t, cy + dy*t +} + +// bindArrow applique les raccourcis start/end d'une flèche. +func bindArrow(a element, byID map[string]element) error { + var ends [2]element + for i, k := range []string{"start", "end"} { + ref, ok := a[k].(map[string]any) + if !ok { + continue + } + delete(a, k) + id, _ := ref["id"].(string) + target, ok := byID[id] + if !ok { + return fmt.Errorf("flèche %v : %s vers l'élément inconnu %q", a["id"], k, id) + } + ends[i] = target + a[k+"Binding"] = map[string]any{"elementId": id, "focus": 0, "gap": 4} + addBound(target, a["id"].(string), "arrow") + } + if _, has := a["points"]; has || ends[0] == nil || ends[1] == nil { + return nil + } + sx, sy := center(ends[0]) + ex, ey := center(ends[1]) + x1, y1 := edge(ends[0], ex, ey, 4) + x2, y2 := edge(ends[1], sx, sy, 4) + a["x"], a["y"] = x1, y1 + a["points"] = []any{[]any{0.0, 0.0}, []any{x2 - x1, y2 - y1}} + return nil +} + +// linear complète une flèche ou une ligne : points et encombrement. +func linear(e element) { + pts, ok := e["points"].([]any) + if !ok || len(pts) < 2 { + pts = []any{[]any{0.0, 0.0}, []any{numOr(e, "width", 100), numOr(e, "height", 0)}} + e["points"] = pts + } + minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) + for _, p := range pts { + q := point(p) + minX, maxX = math.Min(minX, q[0]), math.Max(maxX, q[0]) + minY, maxY = math.Min(minY, q[1]), math.Max(maxY, q[1]) + } + e["width"], e["height"] = maxX-minX, maxY-minY + setDefault(e, "lastCommittedPoint", nil) + setDefault(e, "startBinding", nil) + setDefault(e, "endBinding", nil) + setDefault(e, "startArrowhead", nil) + if e["type"] == "arrow" { + setDefault(e, "endArrowhead", "arrow") + setDefault(e, "elbowed", false) + } else { + setDefault(e, "endArrowhead", nil) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..158dfbf --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,360 @@ +package mcp + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/vblogio/excalidraw-go/internal/storage" +) + +const instructions = `Excalicloud stocke des schémas Excalidraw (.excalidraw) dans une arborescence de dossiers partagée entre les utilisateurs. +Chemins : relatifs à la racine, séparés par "/" ("" = racine). ".perso/..." est l'espace personnel de l'utilisateur (privé) ; ".favoris" liste ses favoris (lecture seule ici). +Pour créer un schéma : write_diagram avec des éléments Excalidraw. Raccourcis acceptés : "label": {"text": "..."} sur rectangle/ellipse/diamond/arrow, et "start"/"end": {"id": ""} sur une flèche pour la relier (points calculés automatiquement). +Avant de modifier un schéma existant, le lire avec read_diagram, puis le réécrire en entier avec overwrite: true (les versions précédentes restent dans l'historique). Donner à l'utilisateur le lien "url" renvoyé.` + +// tool décrit un outil MCP (tools/list). +type tool struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + Annotations map[string]any `json:"annotations,omitempty"` +} + +func obj(required []string, props map[string]any) map[string]any { + s := map[string]any{"type": "object", "properties": props, "additionalProperties": false} + if len(required) > 0 { + s["required"] = required + } + return s +} + +func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } +func boolean(desc string) map[string]any { + return map[string]any{"type": "boolean", "description": desc} +} + +var readOnly = map[string]any{"readOnlyHint": true, "openWorldHint": false} + +var tools = []tool{ + { + Name: "list_diagrams", + Title: "Lister les schémas", + Description: "Liste les dossiers et schémas d'un dossier. À la racine apparaissent aussi .perso (espace personnel) et .favoris.", + InputSchema: obj(nil, map[string]any{ + "path": str(`Dossier à lister ("" ou absent : racine), ex. "réseau/nsx" ou ".perso".`), + "recursive": boolean("Parcourir aussi les sous-dossiers (500 entrées au plus). Défaut : false."), + }), + Annotations: readOnly, + }, + { + Name: "search_diagrams", + Title: "Rechercher des schémas", + Description: "Recherche dans les noms, les tags et le texte des schémas (casse et accents ignorés, tous les mots requis).", + InputSchema: obj(nil, map[string]any{ + "query": str("Mots recherchés (peut être vide si tag ou favorites est donné)."), + "tag": str("Ne garder que les éléments portant ce tag."), + "favorites": boolean("Ne garder que les favoris de l'utilisateur."), + "limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 200, "description": "Nombre maximal de résultats (défaut 50)."}, + }), + Annotations: readOnly, + }, + { + Name: "read_diagram", + Title: "Lire un schéma", + Description: "Renvoie les éléments Excalidraw et l'appState d'un schéma. Les images embarquées (files) sont omises par défaut ; elles sont conservées si le schéma est réécrit sans champ files.", + InputSchema: obj([]string{"path"}, map[string]any{ + "path": str(`Chemin du schéma, ex. "réseau/nsx.excalidraw".`), + "include_files": boolean("Inclure les images embarquées (base64, volumineux). Défaut : false."), + }), + Annotations: readOnly, + }, + { + Name: "write_diagram", + Title: "Créer ou remplacer un schéma", + Description: `Crée un schéma (ou le remplace avec overwrite: true). Les dossiers parents sont créés au besoin ; l'extension .excalidraw est ajoutée si absente. +Éléments : objets Excalidraw ; seuls type et la géométrie sont nécessaires, le reste est complété (id, couleurs, seed…). +- rectangle / ellipse / diamond : x, y, width, height ; option backgroundColor, strokeColor, fillStyle ("solid", "hachure"), strokeStyle ("dashed"…), "label": {"text": "...", "fontSize": 20}. +- text : x, y, text ("\n" pour les retours à la ligne), fontSize (20), fontFamily (5 Excalifont, 2 Helvetica, 3 Cascadia). +- arrow / line : x, y, points ([[0,0],[dx,dy],...] relatifs à x,y), ou pour relier deux formes "start": {"id": "a"}, "end": {"id": "b"} sans points ; "label" possible ; endArrowhead ("arrow", null…). +- frame : x, y, width, height, name ; ses enfants portent frameId. +Palette Excalidraw : #1e1e1e, #e03131, #2f9e44, #1971c2, #f08c00 ; fonds clairs #ffc9c9, #b2f2bb, #a5d8ff, #ffec99.`, + InputSchema: obj([]string{"path", "elements"}, map[string]any{ + "path": str(`Chemin du schéma, ex. "réseau/vcf-overlay.excalidraw" ou ".perso/brouillon".`), + "elements": map[string]any{"type": "array", "items": map[string]any{"type": "object"}, "description": "Éléments Excalidraw de la scène complète."}, + "appState": map[string]any{"type": "object", "description": `Optionnel, ex. {"viewBackgroundColor": "#ffffff"}.`}, + "files": map[string]any{"type": "object", "description": "Optionnel : images embarquées (fileId → {mimeType, id, dataURL})."}, + "overwrite": boolean("Remplacer le schéma s'il existe. Défaut : false (erreur si le schéma existe)."), + }), + Annotations: map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false}, + }, + { + Name: "create_folder", + Title: "Créer un dossier", + Description: "Crée un dossier (et ses parents).", + InputSchema: obj([]string{"path"}, map[string]any{ + "path": str(`Chemin du dossier, ex. "réseau/nsx" ou ".perso/projets".`), + }), + Annotations: map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}, + }, +} + +type call struct { + s *Server + r *http.Request + u *storage.Space +} + +var handlers = map[string]func(*call, json.RawMessage) (map[string]any, error){ + "list_diagrams": (*call).list, + "search_diagrams": (*call).search, + "read_diagram": (*call).read, + "write_diagram": (*call).write, + "create_folder": (*call).mkdir, +} + +// argError signale des arguments invalides (message renvoyé au modèle). +type argError struct{ msg string } + +func (e argError) Error() string { return "argument invalide : " + e.msg } + +func decode(raw json.RawMessage, v any) error { + d := json.NewDecoder(strings.NewReader(string(raw))) + d.DisallowUnknownFields() + if err := d.Decode(v); err != nil { + return argError{err.Error()} + } + return nil +} + +// link renvoie l'adresse du schéma dans l'interface. +func (c *call) link(p string) string { + parts := strings.Split(p, "/") + for i, s := range parts { + parts[i] = url.PathEscape(s) + } + return c.s.BaseURL(c.r) + "/#/" + strings.Join(parts, "/") +} + +type entry struct { + Path string `json:"path"` + Type string `json:"type"` // folder ou diagram + Modified time.Time `json:"modified,omitzero"` + Tags []string `json:"tags,omitempty"` + Favorite bool `json:"favorite,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` +} + +func (c *call) entry(e storage.Entry) entry { + out := entry{Path: e.Path, Type: "diagram", Modified: e.ModTime, Tags: e.Tags, Favorite: e.Favorite} + if e.IsDir || e.Special != "" { + out.Type = "folder" + out.Modified = time.Time{} + } else { + out.URL = c.link(e.Path) + } + return out +} + +const maxList = 500 + +func (c *call) list(raw json.RawMessage) (map[string]any, error) { + var a struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` + } + if err := decode(raw, &a); err != nil { + return nil, err + } + out := []entry{} + queue := []string{strings.Trim(a.Path, "/")} + truncated := false + for len(queue) > 0 && !truncated { + dir := queue[0] + queue = queue[1:] + es, err := c.u.List(dir) + if err != nil { + return nil, err + } + for _, e := range es { + if len(out) == maxList { + truncated = true + break + } + out = append(out, c.entry(e)) + if a.Recursive && (e.IsDir || e.Special == "perso") { + queue = append(queue, e.Path) + } + } + } + return map[string]any{"path": a.Path, "entries": out, "truncated": truncated}, nil +} + +func (c *call) search(raw json.RawMessage) (map[string]any, error) { + var a struct { + Query string `json:"query"` + Tag string `json:"tag"` + Favorites bool `json:"favorites"` + Limit int `json:"limit"` + } + if err := decode(raw, &a); err != nil { + return nil, err + } + if a.Limit <= 0 || a.Limit > 200 { + a.Limit = 50 + } + if strings.TrimSpace(a.Query) == "" && a.Tag == "" && !a.Favorites { + return nil, argError{"query, tag ou favorites requis"} + } + hits, err := c.u.Search(a.Query, storage.Filter{Tag: a.Tag, Favorite: a.Favorites}, a.Limit) + if err != nil { + return nil, err + } + out := []entry{} + for _, h := range hits { + e := c.entry(h.Entry) + e.Snippet = h.Snippet + out = append(out, e) + } + return map[string]any{"results": out}, nil +} + +type scene struct { + Type string `json:"type"` + Version int `json:"version"` + Source string `json:"source"` + Elements []any `json:"elements"` + AppState map[string]any `json:"appState"` + Files map[string]any `json:"files"` +} + +func (c *call) read(raw json.RawMessage) (map[string]any, error) { + var a struct { + Path string `json:"path"` + IncludeFiles bool `json:"include_files"` + } + if err := decode(raw, &a); err != nil { + return nil, err + } + data, _, err := c.u.Read(a.Path) + if err != nil { + return nil, err + } + var sc scene + if err := json.Unmarshal(data, &sc); err != nil { + return nil, storage.ErrInvalidJSON + } + els := []any{} + for _, e := range sc.Elements { + if m, ok := e.(map[string]any); ok && m["isDeleted"] == true { + continue + } + els = append(els, e) + } + out := map[string]any{"path": a.Path, "url": c.link(a.Path), "elements": els, "appState": sc.AppState} + if a.IncludeFiles { + out["files"] = sc.Files + } else if len(sc.Files) > 0 { + out["filesOmitted"] = len(sc.Files) + } + return out, nil +} + +func (c *call) write(raw json.RawMessage) (map[string]any, error) { + var a struct { + Path string `json:"path"` + Elements []any `json:"elements"` + AppState map[string]any `json:"appState"` + Files map[string]any `json:"files"` + Overwrite bool `json:"overwrite"` + } + if err := decode(raw, &a); err != nil { + return nil, err + } + p := strings.Trim(a.Path, "/") + if p == "" { + return nil, argError{"path requis"} + } + if path.Ext(p) != storage.Ext { + p += storage.Ext + } + if a.Elements == nil { + return nil, argError{"elements requis (tableau, éventuellement vide)"} + } + els, err := normalize(a.Elements) + if err != nil { + return nil, argError{err.Error()} + } + sc := scene{Type: "excalidraw", Version: 2, Source: "excalicloud-mcp", Elements: els, AppState: a.AppState, Files: a.Files} + if sc.AppState == nil { + sc.AppState = map[string]any{} + } + if _, ok := sc.AppState["viewBackgroundColor"]; !ok { + sc.AppState["viewBackgroundColor"] = "#ffffff" + } + if sc.Files == nil { + sc.Files = map[string]any{} + } + created := true + if a.Overwrite { + old, _, err := c.u.Read(p) + switch { + case err == nil: + created = false + keepFiles(&sc, old) + case !errors.Is(err, storage.ErrNotFound): + return nil, err + } + } + data, err := json.Marshal(sc) + if err != nil { + return nil, err + } + e, _, err := c.u.Write(p, data, "", !a.Overwrite) + if err != nil { + return nil, err + } + return map[string]any{"path": e.Path, "url": c.link(e.Path), "created": created, "elements": len(els)}, nil +} + +// keepFiles reprend de l'ancien contenu les images encore référencées que la +// nouvelle scène ne fournit pas (read_diagram les omet par défaut). +func keepFiles(sc *scene, old []byte) { + var prev scene + if json.Unmarshal(old, &prev) != nil { + return + } + for _, e := range sc.Elements { + m, _ := e.(map[string]any) + id, _ := m["fileId"].(string) + if id == "" { + continue + } + if _, ok := sc.Files[id]; !ok && prev.Files[id] != nil { + sc.Files[id] = prev.Files[id] + } + } +} + +func (c *call) mkdir(raw json.RawMessage) (map[string]any, error) { + var a struct { + Path string `json:"path"` + } + if err := decode(raw, &a); err != nil { + return nil, err + } + e, err := c.u.Mkdir(a.Path) + if errors.Is(err, storage.ErrExists) { + return map[string]any{"path": strings.Trim(a.Path, "/"), "created": false}, nil + } + if err != nil { + return nil, err + } + return map[string]any{"path": e.Path, "created": true}, nil +} diff --git a/internal/tokens/tokens_test.go b/internal/tokens/tokens_test.go new file mode 100644 index 0000000..4d0ad11 --- /dev/null +++ b/internal/tokens/tokens_test.go @@ -0,0 +1,288 @@ +package tokens + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" + + "github.com/vblogio/excalidraw-go/internal/auth" +) + +// newServer monte le serveur de jetons devant un mux de test : /whoami +// renvoie l'utilisateur vu par l'application, /mcp est la ressource protégée. +// L'utilisateur de session est simulé par l'en-tête X-User. +func newServer(t *testing.T) (*Server, http.Handler) { + t.Helper() + st, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + userID := func(r *http.Request) string { + if u := auth.UserFrom(r.Context()); u != nil { + return u.Email + } + return "local" + } + s := &Server{Store: st, PublicURL: "https://ex.test", UserID: userID, RedirectHosts: []string{"claude.ai"}, ResourcePaths: []string{"/mcp"}} + mux := http.NewServeMux() + s.Register(mux) + who := func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(userID(r))) } + mux.HandleFunc("/whoami", who) + mux.HandleFunc("/mcp", who) + // Session simulée : X-User porte l'email de l'utilisateur connecté. + session := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if e := r.Header.Get("X-User"); e != "" { + r = r.WithContext(auth.WithUser(r.Context(), &auth.User{Sub: e, Name: e, Email: e})) + } + mux.ServeHTTP(w, r) + }) + return s, s.Middleware(session, mux) +} + +func do(h http.Handler, method, target, user, bearer string, body string, ctype string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + if user != "" { + req.Header.Set("X-User", user) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + if ctype != "" { + req.Header.Set("Content-Type", ctype) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestPersonalTokens(t *testing.T) { + _, h := newServer(t) + rec := do(h, "POST", "/api/tokens", "a@x.fr", "", `{"name":" Claude Code "}`, "application/json") + if rec.Code != 201 { + t.Fatalf("création : %d %s", rec.Code, rec.Body) + } + var created struct { + Info + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if !strings.HasPrefix(created.Token, apiPrefix) || created.Name != "Claude Code" { + t.Fatalf("jeton = %+v", created) + } + if strings.Contains(do(h, "GET", "/api/tokens", "a@x.fr", "", "", "").Body.String(), created.Token[4:]) { + t.Fatal("jeton en clair dans la liste") + } + if got := do(h, "GET", "/whoami", "", created.Token, "", "").Body.String(); got != "a@x.fr" { + t.Fatalf("utilisateur du jeton = %q", got) + } + // Un jeton ne gère pas les jetons ; b ne voit ni ne révoque ceux de a. + if rec := do(h, "GET", "/api/tokens", "", created.Token, "", ""); rec.Code != 403 { + t.Fatalf("liste par jeton : %d", rec.Code) + } + if body := do(h, "GET", "/api/tokens", "b@x.fr", "", "", "").Body.String(); strings.TrimSpace(body) != "[]" { + t.Fatalf("b voit : %s", body) + } + if rec := do(h, "DELETE", "/api/tokens/"+created.ID, "b@x.fr", "", "", ""); rec.Code != 404 { + t.Fatalf("révocation par b : %d", rec.Code) + } + if rec := do(h, "DELETE", "/api/tokens/"+created.ID, "a@x.fr", "", "", ""); rec.Code != 204 { + t.Fatalf("révocation : %d", rec.Code) + } + if rec := do(h, "GET", "/whoami", "", created.Token, "", ""); rec.Code != 401 || !strings.Contains(rec.Header().Get("WWW-Authenticate"), `error="invalid_token"`) { + t.Fatalf("jeton révoqué : %d %v", rec.Code, rec.Header()) + } + for _, name := range []string{"", strings.Repeat("x", MaxNameLen+1)} { + if rec := do(h, "POST", "/api/tokens", "a@x.fr", "", `{"name":"`+name+`"}`, ""); rec.Code != 400 { + t.Errorf("nom %q : %d", name, rec.Code) + } + } +} + +func TestStorePersistence(t *testing.T) { + dir := t.TempDir() + st, _ := Open(dir) + secret, _, err := st.Create("a", nil, "x") + if err != nil { + t.Fatal(err) + } + st2, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if tok, ok := st2.Lookup(secret); !ok || tok.UserID != "a" || tok.User != nil { + t.Fatalf("après rechargement : %+v %v", tok, ok) + } + if string(st2.Key()) != string(st.Key()) { + t.Fatal("clé HMAC régénérée") + } +} + +func TestResourceRequiresToken(t *testing.T) { + _, h := newServer(t) + rec := do(h, "POST", "/mcp", "a@x.fr", "", "{}", "") + want := `Bearer resource_metadata="https://ex.test/.well-known/oauth-protected-resource"` + if rec.Code != 401 || rec.Header().Get("WWW-Authenticate") != want { + t.Fatalf("/mcp sans jeton : %d %q", rec.Code, rec.Header().Get("WWW-Authenticate")) + } + // Les autres routes suivent la session. + if got := do(h, "GET", "/whoami", "a@x.fr", "", "", "").Body.String(); got != "a@x.fr" { + t.Fatalf("session : %q", got) + } +} + +func pkce() (verifier, challenge string) { + verifier = strings.Repeat("v", 50) + sum := sha256.Sum256([]byte(verifier)) + return verifier, base64.RawURLEncoding.EncodeToString(sum[:]) +} + +var hidden = regexp.MustCompile(`name="(\w+)" value="([^"]*)"`) + +func TestOAuthFlow(t *testing.T) { + s, h := newServer(t) + const cb = "https://claude.ai/api/mcp/auth_callback" + + var meta map[string]any + _ = json.Unmarshal(do(h, "GET", "/.well-known/oauth-protected-resource/mcp", "", "", "", "").Body.Bytes(), &meta) + if meta["resource"] != "https://ex.test/mcp" { + t.Fatalf("ressource = %v", meta) + } + _ = json.Unmarshal(do(h, "GET", "/.well-known/oauth-authorization-server", "", "", "", "").Body.Bytes(), &meta) + if meta["registration_endpoint"] != "https://ex.test/oauth/register" { + t.Fatalf("métadonnées = %v", meta) + } + + // Enregistrement dynamique : redirection limitée aux hôtes autorisés. + if rec := do(h, "POST", "/oauth/register", "", "", `{"client_name":"Evil","redirect_uris":["https://evil.test/cb"]}`, "application/json"); rec.Code != 400 { + t.Fatalf("redirection externe acceptée : %d", rec.Code) + } + rec := do(h, "POST", "/oauth/register", "", "", `{"client_name":"Claude","redirect_uris":["`+cb+`"]}`, "application/json") + var reg struct { + ClientID string `json:"client_id"` + } + if rec.Code != 201 || json.Unmarshal(rec.Body.Bytes(), ®) != nil || reg.ClientID == "" { + t.Fatalf("enregistrement : %d %s", rec.Code, rec.Body) + } + + verifier, challenge := pkce() + q := url.Values{"response_type": {"code"}, "client_id": {reg.ClientID}, "redirect_uri": {cb}, + "code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {"xyz"}} + if rec := do(h, "GET", "/oauth/authorize?"+strings.Replace(q.Encode(), "claude.ai", "evil.test", 1), "a@x.fr", "", "", ""); rec.Code != 400 { + t.Fatalf("redirection non enregistrée : %d", rec.Code) + } + page := do(h, "GET", "/oauth/authorize?"+q.Encode(), "a@x.fr", "", "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), "a@x.fr") { + t.Fatalf("consentement : %d %s", page.Code, page.Body) + } + form := url.Values{} + for _, m := range hidden.FindAllStringSubmatch(page.Body.String(), -1) { + form.Set(m[1], strings.ReplaceAll(m[2], "+", "+")) + } + form.Set("action", "allow") + + // Le formulaire est lié à l'utilisateur : b ne peut pas le rejouer. + if rec := do(h, "POST", "/oauth/authorize", "b@x.fr", "", form.Encode(), "application/x-www-form-urlencoded"); rec.Code != 400 { + t.Fatalf("formulaire rejoué par b : %d", rec.Code) + } + rec = do(h, "POST", "/oauth/authorize", "a@x.fr", "", form.Encode(), "application/x-www-form-urlencoded") + loc, _ := url.Parse(rec.Header().Get("Location")) + code := loc.Query().Get("code") + if rec.Code != 302 || code == "" || loc.Query().Get("state") != "xyz" || !strings.HasPrefix(loc.String(), cb) { + t.Fatalf("redirection : %d %v", rec.Code, loc) + } + + tokenReq := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "client_id": {reg.ClientID}, + "redirect_uri": {cb}, "code_verifier": {"mauvais" + verifier}} + if rec := do(h, "POST", "/oauth/token", "", "", tokenReq.Encode(), "application/x-www-form-urlencoded"); rec.Code != 400 { + t.Fatalf("PKCE incorrect accepté : %d", rec.Code) + } + // Le code est à usage unique, même après un échec : recommencer. + rec = do(h, "POST", "/oauth/authorize", "a@x.fr", "", form.Encode(), "application/x-www-form-urlencoded") + loc, _ = url.Parse(rec.Header().Get("Location")) + tokenReq.Set("code", loc.Query().Get("code")) + tokenReq.Set("code_verifier", verifier) + rec = do(h, "POST", "/oauth/token", "", "", tokenReq.Encode(), "application/x-www-form-urlencoded") + var tok struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &tok) != nil || tok.Access == "" { + t.Fatalf("échange : %d %s", rec.Code, rec.Body) + } + if got := do(h, "POST", "/mcp", "", tok.Access, "", "").Body.String(); got != "a@x.fr" { + t.Fatalf("/mcp avec jeton OAuth : %q", got) + } + if rec := do(h, "POST", "/oauth/token", "", "", tokenReq.Encode(), "application/x-www-form-urlencoded"); rec.Code != 400 { + t.Fatalf("code rejoué : %d", rec.Code) + } + + // Rafraîchissement avec rotation. + ref := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {tok.Refresh}, "client_id": {reg.ClientID}} + rec = do(h, "POST", "/oauth/token", "", "", ref.Encode(), "application/x-www-form-urlencoded") + var tok2 struct { + Access string `json:"access_token"` + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &tok2) != nil || tok2.Access == tok.Access { + t.Fatalf("rafraîchissement : %d %s", rec.Code, rec.Body) + } + if rec := do(h, "POST", "/mcp", "", tok.Access, "", ""); rec.Code != 401 { + t.Fatalf("ancien jeton d'accès encore valable : %d", rec.Code) + } + if rec := do(h, "POST", "/oauth/token", "", "", ref.Encode(), "application/x-www-form-urlencoded"); rec.Code != 400 { + t.Fatalf("ancien jeton de rafraîchissement accepté : %d", rec.Code) + } + + // Expiration du jeton d'accès, puis révocation depuis l'interface. + s.Store.now = func() time.Time { return time.Now().Add(2 * AccessTTL) } + if rec := do(h, "POST", "/mcp", "", tok2.Access, "", ""); rec.Code != 401 { + t.Fatalf("jeton expiré accepté : %d", rec.Code) + } + s.Store.now = time.Now + var list []Info + _ = json.Unmarshal(do(h, "GET", "/api/tokens", "a@x.fr", "", "", "").Body.Bytes(), &list) + if len(list) != 1 || list[0].Kind != KindOAuth || list[0].Name != "Claude" || list[0].Client != "claude.ai" { + t.Fatalf("liste = %+v", list) + } + do(h, "DELETE", "/api/tokens/"+list[0].ID, "a@x.fr", "", "", "") + if rec := do(h, "POST", "/mcp", "", tok2.Access, "", ""); rec.Code != 401 { + t.Fatalf("autorisation révoquée encore valable : %d", rec.Code) + } +} + +func TestOAuthDenyAndLoopback(t *testing.T) { + _, h := newServer(t) + rec := do(h, "POST", "/oauth/register", "", "", `{"client_name":"Claude Code","redirect_uris":["http://localhost/callback"]}`, "") + var reg struct { + ClientID string `json:"client_id"` + } + _ = json.Unmarshal(rec.Body.Bytes(), ®) + _, challenge := pkce() + // Port éphémère de Claude Code : accepté pour une adresse de bouclage. + q := url.Values{"response_type": {"code"}, "client_id": {reg.ClientID}, "redirect_uri": {"http://localhost:3118/callback"}, + "code_challenge": {challenge}, "code_challenge_method": {"S256"}} + page := do(h, "GET", "/oauth/authorize?"+q.Encode(), "", "", "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), "utilisateur local") { + t.Fatalf("consentement : %d %s", page.Code, page.Body) + } + form := url.Values{} + for _, m := range hidden.FindAllStringSubmatch(page.Body.String(), -1) { + form.Set(m[1], m[2]) + } + form.Set("action", "deny") + rec = do(h, "POST", "/oauth/authorize", "", "", form.Encode(), "application/x-www-form-urlencoded") + if loc := rec.Header().Get("Location"); rec.Code != 302 || !strings.Contains(loc, "error=access_denied") || !strings.HasPrefix(loc, "http://localhost:3118/callback") { + t.Fatalf("refus : %d %s", rec.Code, loc) + } + // Sans PKCE : erreur renvoyée au client. + q.Del("code_challenge") + if rec := do(h, "GET", "/oauth/authorize?"+q.Encode(), "", "", "", ""); rec.Code != 302 || !strings.Contains(rec.Header().Get("Location"), "invalid_request") { + t.Fatalf("sans PKCE : %d", rec.Code) + } +}