package main import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) // TestServiceOpEndToEnd drives the interactive write flow of // docs/integrations.md against the real router: Owner asks Eleven to register // a domain → the (scripted) brain proposes a service_op chip whose label is // the manifest's rendered confirm line → the member's "tap" seals a one-shot // WRITE grant to Eleven's key and posts the connect.op → the sidecar's pinned // executor runs the op against the (fake) registrar → the connect.result // lands back in the DM. Then the negative: the same op under a READ grant is // refused without the registrar ever seeing a second request. func TestServiceOpEndToEnd(t *testing.T) { // The fake registrar records every request the executor makes. type regReq struct { method, path, auth, body string } var regMu sync.Mutex var regReqs []regReq fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) regMu.Lock() regReqs = append(regReqs, regReq{r.Method, r.URL.Path, r.Header.Get("Authorization"), string(raw)}) regMu.Unlock() w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"data":{"id":1,"name":"blip.com"}}`)) })) defer fake.Close() t.Setenv("LCHAT_INTEGRATIONS_BASE_DNSIMPLE", fake.URL) claude := newFakeAnthropic(t, "Here's the button — it costs real money, so it's your tap.", map[string]any{"name": "service_op", "input": map[string]any{ "service": "dnsimple", "op": "domain.register", "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"}, }}) t.Setenv("ANTHROPIC_BASE_URL", claude.srv.URL) ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) eleven := provisionNamed(t, ts.URL, "Eleven", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) dir := t.TempDir() sessionPath := dir + "/eleven.session.json" if err := eleven.Save(sessionPath); err != nil { t.Fatal(err) } ready := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf} go eng.Run(ctx) awaitReady(t, ready) postAs(t, ts.URL, owner, dmID, dmKey, "@Eleven register blip.com on dnsimple, account 123, registrant 77") // The reply carries the write chip, its label the manifest's confirm line. waitFor(t, "service_op chip", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 }) replies := botReplies(t, s, dmID, dmKey, "Eleven") if len(replies[0].Actions) != 1 { t.Fatalf("want the service_op chip, got %+v", replies[0].Actions) } chip := replies[0].Actions[0] if chip["type"] != "service_op" || chip["access"] != "write" || chip["service"] != "dnsimple" || chip["op"] != "domain.register" { t.Fatalf("bad chip: %+v", chip) } if chip["label"] != "Register blip.com? This buys the domain and costs real money." { t.Fatalf("chip label isn't the rendered confirm line: %q", chip["label"]) } args, _ := chip["args"].(map[string]any) if args["domain"] != "blip.com" || args["account"] != "123" || args["registrant_id"] != "77" { t.Fatalf("chip args: %+v", args) } regMu.Lock() if len(regReqs) != 0 { t.Fatalf("proposal alone reached the registrar: %+v", regReqs) } regMu.Unlock() // The tap: shell.js seals a one-shot WRITE grant to the bot's key and posts // connect.op with the chip's args (requestOp). tap := func(rid, access string) { grant, err := e2ee.SealForPub(eleven.UserPub, map[string]any{ "service": "dnsimple", "token": "scoped-registrar-tok", "exp": time.Now().Add(2 * time.Minute).UnixMilli(), "access": access, }) if err != nil { t.Fatal(err) } ownerUID, _ := s.db.deviceTokenUser(owner.Token) postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ "type": "connect.op", "service": "dnsimple", "op": "domain.register", "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"}, "grant": grant, "rid": rid, "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), }) } tap("rid-write", "write") waitFor(t, "connect.result", func() bool { p := typedReply(t, s, dmID, dmKey, "connect.result") return p != nil && p["rid"] == "rid-write" }) res := typedReply(t, s, dmID, dmKey, "connect.result") if res["error"] != nil { t.Fatalf("write op errored: %v", res["error"]) } var data struct { Data struct { Name string `json:"name"` } `json:"data"` } if body, _ := res["data"].(string); json.Unmarshal([]byte(body), &data) != nil || data.Data.Name != "blip.com" { t.Fatalf("result data: %v", res["data"]) } regMu.Lock() if len(regReqs) != 1 { t.Fatalf("registrar saw %d requests, want 1: %+v", len(regReqs), regReqs) } got := regReqs[0] regMu.Unlock() if got.method != "POST" || got.path != "/v2/123/registrar/domains/blip.com/registrations" { t.Fatalf("registrar request: %s %s", got.method, got.path) } if got.auth != "Bearer scoped-registrar-tok" { t.Fatalf("registrar auth: %q", got.auth) } if !strings.Contains(got.body, `"registrant_id":"77"`) { t.Fatalf("registrar body: %q", got.body) } // The negative: the same op under a READ grant must be refused, and the // registrar must never see a second request. tap("rid-read", "read") waitFor(t, "read-grant refusal", func() bool { msgs, err := s.db.messagesAfter(dmID, 0, 200) if err != nil { return false } for _, m := range msgs { var p map[string]any if e2ee.DecryptJSON(dmKey, m.Blob, &p) == nil && p["type"] == "connect.result" && p["rid"] == "rid-read" { e, _ := p["error"].(string) return strings.Contains(e, "read-only") } } return false }) regMu.Lock() defer regMu.Unlock() if len(regReqs) != 1 { t.Fatalf("read grant reached the registrar: %+v", regReqs) } }