| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "lchat/internal/bot" |
| 15 | "lchat/internal/e2ee" |
| 16 | ) |
| 17 | |
| 18 | // TestServiceOpEndToEnd drives the interactive write flow of |
| 19 | // docs/integrations.md against the real router: Owner asks Eleven to register |
| 20 | // a domain → the (scripted) brain proposes a service_op chip whose label is |
| 21 | // the manifest's rendered confirm line → the member's "tap" seals a one-shot |
| 22 | // WRITE grant to Eleven's key and posts the connect.op → the sidecar's pinned |
| 23 | // executor runs the op against the (fake) registrar → the connect.result |
| 24 | // lands back in the DM. Then the negative: the same op under a READ grant is |
| 25 | // refused without the registrar ever seeing a second request. |
| 26 | func TestServiceOpEndToEnd(t *testing.T) { |
| 27 | // The fake registrar records every request the executor makes. |
| 28 | type regReq struct { |
| 29 | method, path, auth, body string |
| 30 | } |
| 31 | var regMu sync.Mutex |
| 32 | var regReqs []regReq |
| 33 | fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 34 | raw, _ := io.ReadAll(r.Body) |
| 35 | regMu.Lock() |
| 36 | regReqs = append(regReqs, regReq{r.Method, r.URL.Path, r.Header.Get("Authorization"), string(raw)}) |
| 37 | regMu.Unlock() |
| 38 | w.WriteHeader(http.StatusCreated) |
| 39 | w.Write([]byte(`{"data":{"id":1,"name":"blip.com"}}`)) |
| 40 | })) |
| 41 | defer fake.Close() |
| 42 | t.Setenv("LCHAT_INTEGRATIONS_BASE_DNSIMPLE", fake.URL) |
| 43 | |
| 44 | claude := newFakeAnthropic(t, "Here's the button — it costs real money, so it's your tap.", |
| 45 | map[string]any{"name": "service_op", "input": map[string]any{ |
| 46 | "service": "dnsimple", "op": "domain.register", |
| 47 | "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"}, |
| 48 | }}) |
| 49 | t.Setenv("ANTHROPIC_BASE_URL", claude.srv.URL) |
| 50 | |
| 51 | ts, s := newTestServer(t) |
| 52 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 53 | eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| 54 | dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) |
| 55 | |
| 56 | dir := t.TempDir() |
| 57 | sessionPath := dir + "/eleven.session.json" |
| 58 | if err := eleven.Save(sessionPath); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | ready := make(chan struct{}) |
| 62 | ctx, cancel := context.WithCancel(context.Background()) |
| 63 | defer cancel() |
| 64 | eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, |
| 65 | Recipe: bot.DefaultRecipe(), AIKey: "test-key", |
| 66 | Ready: func() { close(ready) }, Logf: t.Logf} |
| 67 | go eng.Run(ctx) |
| 68 | awaitReady(t, ready) |
| 69 | |
| 70 | postAs(t, ts.URL, owner, dmID, dmKey, "@Eleven register blip.com on dnsimple, account 123, registrant 77") |
| 71 | |
| 72 | // The reply carries the write chip, its label the manifest's confirm line. |
| 73 | waitFor(t, "service_op chip", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 }) |
| 74 | replies := botReplies(t, s, dmID, dmKey, "Eleven") |
| 75 | if len(replies[0].Actions) != 1 { |
| 76 | t.Fatalf("want the service_op chip, got %+v", replies[0].Actions) |
| 77 | } |
| 78 | chip := replies[0].Actions[0] |
| 79 | if chip["type"] != "service_op" || chip["access"] != "write" || chip["service"] != "dnsimple" || chip["op"] != "domain.register" { |
| 80 | t.Fatalf("bad chip: %+v", chip) |
| 81 | } |
| 82 | if chip["label"] != "Register blip.com? This buys the domain and costs real money." { |
| 83 | t.Fatalf("chip label isn't the rendered confirm line: %q", chip["label"]) |
| 84 | } |
| 85 | args, _ := chip["args"].(map[string]any) |
| 86 | if args["domain"] != "blip.com" || args["account"] != "123" || args["registrant_id"] != "77" { |
| 87 | t.Fatalf("chip args: %+v", args) |
| 88 | } |
| 89 | regMu.Lock() |
| 90 | if len(regReqs) != 0 { |
| 91 | t.Fatalf("proposal alone reached the registrar: %+v", regReqs) |
| 92 | } |
| 93 | regMu.Unlock() |
| 94 | |
| 95 | // The tap: shell.js seals a one-shot WRITE grant to the bot's key and posts |
| 96 | // connect.op with the chip's args (requestOp). |
| 97 | tap := func(rid, access string) { |
| 98 | grant, err := e2ee.SealForPub(eleven.UserPub, map[string]any{ |
| 99 | "service": "dnsimple", "token": "scoped-registrar-tok", |
| 100 | "exp": time.Now().Add(2 * time.Minute).UnixMilli(), "access": access, |
| 101 | }) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 106 | postTyped(t, ts.URL, owner, dmID, dmKey, map[string]any{ |
| 107 | "type": "connect.op", "service": "dnsimple", "op": "domain.register", |
| 108 | "args": map[string]any{"account": "123", "domain": "blip.com", "registrant_id": "77"}, |
| 109 | "grant": grant, "rid": rid, "uid": ownerUID, "name": "Owner", "ts": time.Now().UnixMilli(), |
| 110 | }) |
| 111 | } |
| 112 | tap("rid-write", "write") |
| 113 | |
| 114 | waitFor(t, "connect.result", func() bool { |
| 115 | p := typedReply(t, s, dmID, dmKey, "connect.result") |
| 116 | return p != nil && p["rid"] == "rid-write" |
| 117 | }) |
| 118 | res := typedReply(t, s, dmID, dmKey, "connect.result") |
| 119 | if res["error"] != nil { |
| 120 | t.Fatalf("write op errored: %v", res["error"]) |
| 121 | } |
| 122 | var data struct { |
| 123 | Data struct { |
| 124 | Name string `json:"name"` |
| 125 | } `json:"data"` |
| 126 | } |
| 127 | if body, _ := res["data"].(string); json.Unmarshal([]byte(body), &data) != nil || data.Data.Name != "blip.com" { |
| 128 | t.Fatalf("result data: %v", res["data"]) |
| 129 | } |
| 130 | regMu.Lock() |
| 131 | if len(regReqs) != 1 { |
| 132 | t.Fatalf("registrar saw %d requests, want 1: %+v", len(regReqs), regReqs) |
| 133 | } |
| 134 | got := regReqs[0] |
| 135 | regMu.Unlock() |
| 136 | if got.method != "POST" || got.path != "/v2/123/registrar/domains/blip.com/registrations" { |
| 137 | t.Fatalf("registrar request: %s %s", got.method, got.path) |
| 138 | } |
| 139 | if got.auth != "Bearer scoped-registrar-tok" { |
| 140 | t.Fatalf("registrar auth: %q", got.auth) |
| 141 | } |
| 142 | if !strings.Contains(got.body, `"registrant_id":"77"`) { |
| 143 | t.Fatalf("registrar body: %q", got.body) |
| 144 | } |
| 145 | |
| 146 | // The negative: the same op under a READ grant must be refused, and the |
| 147 | // registrar must never see a second request. |
| 148 | tap("rid-read", "read") |
| 149 | waitFor(t, "read-grant refusal", func() bool { |
| 150 | msgs, err := s.db.messagesAfter(dmID, 0, 200) |
| 151 | if err != nil { |
| 152 | return false |
| 153 | } |
| 154 | for _, m := range msgs { |
| 155 | var p map[string]any |
| 156 | if e2ee.DecryptJSON(dmKey, m.Blob, &p) == nil && p["type"] == "connect.result" && p["rid"] == "rid-read" { |
| 157 | e, _ := p["error"].(string) |
| 158 | return strings.Contains(e, "read-only") |
| 159 | } |
| 160 | } |
| 161 | return false |
| 162 | }) |
| 163 | regMu.Lock() |
| 164 | defer regMu.Unlock() |
| 165 | if len(regReqs) != 1 { |
| 166 | t.Fatalf("read grant reached the registrar: %+v", regReqs) |
| 167 | } |
| 168 | } |
| 169 | |