eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package integrations
2
3import (
4 "encoding/json"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10)
11
12// testManifest declares one service with a read op (path + query args), a
13// JSON write op with a nested body and an optional arg, and a form write op —
14// covering every encoding the executor supports.
15const testManifest = `{
16 "schema": 1,
17 "id": "testsvc",
18 "name": "TestSvc",
19 "category": "test",
20 "auth": {
21 "kind": "pat",
22 "base": "https://api.testsvc.example",
23 "authorization": "Token",
24 "headers": { "X-Api-Version": "7" }
25 },
26 "ops": [
27 { "id": "items.list", "label": "List", "access": "read", "method": "GET",
28 "path": "/v1/{account}/items?limit=10&q={q}",
29 "args": { "account": { "required": true }, "q": {} } },
30 { "id": "item.create", "label": "Create", "access": "write", "method": "POST",
31 "path": "/v1/items",
32 "args": { "title": { "required": true }, "note": {} },
33 "body_json": { "props": { "title": "{title}", "note": "{note}", "kind": "widget" } },
34 "confirm": "Create {title}?" },
35 { "id": "item.refund", "label": "Refund", "access": "write", "method": "POST",
36 "path": "/v1/refunds",
37 "args": { "charge": { "required": true } },
38 "body_form": { "charge": "{charge}", "reason": "requested" },
39 "encoding": "form",
40 "confirm": "Refund {charge}?" }
41 ]
42}`
43
44// captured is what the fake service saw for one request.
45type captured struct {
46 method, uri, auth, accept, contentType string
47 body []byte
48}
49
50// fakeService stands up an httptest server, points the executor's env
51// override at it (the LCHAT_DNSIMPLE_BASE pattern), and records each request.
52func fakeService(t *testing.T, status int, respBody string) (*Manifest, *captured) {
53 t.Helper()
54 m, err := Parse([]byte(testManifest))
55 if err != nil {
56 t.Fatalf("test manifest: %v", err)
57 }
58 got := &captured{}
59 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60 got.method = r.Method
61 got.uri = r.URL.RequestURI()
62 got.auth = r.Header.Get("Authorization")
63 got.accept = r.Header.Get("Accept")
64 got.contentType = r.Header.Get("Content-Type")
65 got.body, _ = io.ReadAll(r.Body)
66 w.WriteHeader(status)
67 io.WriteString(w, respBody)
68 }))
69 t.Cleanup(srv.Close)
70 t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL)
71 return m, got
72}
73
74func TestExecuteGetSubstitutesPathAndQuery(t *testing.T) {
75 m, got := fakeService(t, 200, `{"ok":true}`)
76 body, err := Execute(m, "items.list", map[string]string{"account": "acct 1", "q": "a&b"}, "tok123")
77 if err != nil {
78 t.Fatalf("Execute: %v", err)
79 }
80 if body != `{"ok":true}` {
81 t.Errorf("body: %q", body)
82 }
83 // Path slot path-escaped, query slot query-escaped.
84 if got.uri != "/v1/acct%201/items?limit=10&q=a%26b" {
85 t.Errorf("uri: %q", got.uri)
86 }
87 if got.auth != "Token tok123" {
88 t.Errorf("authorization: %q", got.auth)
89 }
90 if got.accept != "application/json" {
91 t.Errorf("accept: %q", got.accept)
92 }
93 if got.method != "GET" {
94 t.Errorf("method: %q", got.method)
95 }
96}
97
98func TestExecutePostJSONNestedBodyDropsAbsentOptionals(t *testing.T) {
99 m, got := fakeService(t, 201, `{"id":1}`)
100 // "note" is optional and absent: its pure-placeholder leaf must be
101 // DROPPED, not sent as "".
102 if _, err := Execute(m, "item.create", map[string]string{"title": "A widget"}, "tk"); err != nil {
103 t.Fatalf("Execute: %v", err)
104 }
105 if got.contentType != "application/json" {
106 t.Errorf("content-type: %q", got.contentType)
107 }
108 var sent struct {
109 Props map[string]any `json:"props"`
110 }
111 if err := json.Unmarshal(got.body, &sent); err != nil {
112 t.Fatalf("body %q: %v", got.body, err)
113 }
114 if sent.Props["title"] != "A widget" || sent.Props["kind"] != "widget" {
115 t.Errorf("props: %v", sent.Props)
116 }
117 if _, present := sent.Props["note"]; present {
118 t.Errorf("absent optional arg was sent: %v", sent.Props)
119 }
120}
121
122func TestExecutePostForm(t *testing.T) {
123 m, got := fakeService(t, 200, `{}`)
124 if _, err := Execute(m, "item.refund", map[string]string{"charge": "ch_1&x=2"}, "tk"); err != nil {
125 t.Fatalf("Execute: %v", err)
126 }
127 if got.contentType != "application/x-www-form-urlencoded" {
128 t.Errorf("content-type: %q", got.contentType)
129 }
130 // url.Values encoding must contain the value, delimiters escaped.
131 if !strings.Contains(string(got.body), "charge=ch_1%26x%3D2") || !strings.Contains(string(got.body), "reason=requested") {
132 t.Errorf("form body: %q", got.body)
133 }
134}
135
136func TestExecuteArgValidation(t *testing.T) {
137 m, _ := fakeService(t, 200, `{}`)
138 if _, err := Execute(m, "items.list", map[string]string{}, "tk"); err == nil || !strings.Contains(err.Error(), "account") {
139 t.Errorf("missing required arg: %v", err)
140 }
141 if _, err := Execute(m, "items.list", map[string]string{"account": "a", "bogus": "x"}, "tk"); err == nil || !strings.Contains(err.Error(), "bogus") {
142 t.Errorf("undeclared arg: %v", err)
143 }
144 if _, err := Execute(m, "items.list", map[string]string{"account": "a\x00b"}, "tk"); err == nil {
145 t.Error("control chars accepted")
146 }
147 if _, err := Execute(m, "items.list", map[string]string{"account": strings.Repeat("a", 3000)}, "tk"); err == nil {
148 t.Error("oversized value accepted")
149 }
150}
151
152func TestExecuteValueCannotBreakOutOfItsSlot(t *testing.T) {
153 m, got := fakeService(t, 200, `{}`)
154 // A hostile value tries path traversal + query injection; it must arrive
155 // fully escaped inside its one path segment.
156 if _, err := Execute(m, "items.list", map[string]string{"account": "../admin?x=1&y=2#f"}, "tk"); err != nil {
157 t.Fatalf("Execute: %v", err)
158 }
159 if !strings.HasPrefix(got.uri, "/v1/..%2Fadmin%3Fx%3D1%26y%3D2%23f/items?") {
160 t.Errorf("hostile value not contained: %q", got.uri)
161 }
162 // No un-escaped delimiter appears between the template's own slashes.
163 seg := strings.TrimPrefix(got.uri, "/v1/")
164 seg = seg[:strings.Index(seg, "/items")]
165 for _, c := range []string{"/", "?", "#", "&"} {
166 if strings.Contains(seg, c) {
167 t.Errorf("un-escaped %q in substituted segment %q", c, seg)
168 }
169 }
170}
171
172func TestExecuteNon2xxSurfacesStatusAndBody(t *testing.T) {
173 m, _ := fakeService(t, 422, `{"message":"Validation Failed"}`)
174 _, err := Execute(m, "items.list", map[string]string{"account": "a"}, "tk")
175 if err == nil || !strings.Contains(err.Error(), "testsvc.items.list: status 422") || !strings.Contains(err.Error(), "Validation Failed") {
176 t.Errorf("non-2xx error: %v", err)
177 }
178}
179
180func TestExecuteUnknownOp(t *testing.T) {
181 m, _ := fakeService(t, 200, `{}`)
182 if _, err := Execute(m, "nope.nothing", nil, "tk"); err == nil || !strings.Contains(err.Error(), "unknown op") {
183 t.Errorf("unknown op: %v", err)
184 }
185}
186
187// graphqlManifest pins the Linear pattern: a bare-token Authorization header
188// ("none" — Linear rejects a Bearer prefix on personal API keys) and GraphQL
189// ops whose query text is full of braces that must pass through the
190// placeholder machinery untouched, member input riding only in the variables
191// map. Every brace in query text keeps a space inside (`{ nodes ` not
192// `{nodes`) so nothing can read as a `{arg}` placeholder.
193const graphqlManifest = `{
194 "schema": 1,
195 "id": "testsvc",
196 "name": "TestSvc",
197 "category": "test",
198 "auth": { "kind": "pat", "base": "https://api.testsvc.example", "authorization": "none" },
199 "ops": [
200 { "id": "issues.list", "label": "Issues", "access": "read", "method": "POST", "path": "/graphql",
201 "args": { "team": { "required": true } },
202 "body_json": {
203 "query": "query($team: String!) { issues(filter: { team: { key: { eq: $team } } }) { nodes { identifier title } } }",
204 "variables": { "team": "{team}" }
205 },
206 "pick": { "root": "data.issues.nodes", "fields": ["identifier", "title"] } }
207 ]
208}`
209
210func TestExecuteGraphQLBareTokenAndBracePassthrough(t *testing.T) {
211 m, err := Parse([]byte(graphqlManifest))
212 if err != nil {
213 t.Fatalf("graphql manifest: %v", err)
214 }
215 got := &captured{}
216 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
217 got.auth = r.Header.Get("Authorization")
218 got.body, _ = io.ReadAll(r.Body)
219 io.WriteString(w, `{"data":{"issues":{"nodes":[{"identifier":"ENG-1","title":"Fix login","estimate":3}]}}}`)
220 }))
221 t.Cleanup(srv.Close)
222 t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL)
223
224 body, err := Execute(m, "issues.list", map[string]string{"team": "ENG"}, "lin_api_tok")
225 if err != nil {
226 t.Fatalf("Execute: %v", err)
227 }
228 // "none" sends the token alone — no scheme prefix.
229 if got.auth != "lin_api_tok" {
230 t.Errorf("authorization: %q", got.auth)
231 }
232 var sent struct {
233 Query string `json:"query"`
234 Variables map[string]string `json:"variables"`
235 }
236 if err := json.Unmarshal(got.body, &sent); err != nil {
237 t.Fatalf("body %q: %v", got.body, err)
238 }
239 // The query's GraphQL braces arrive byte-identical (none is a
240 // placeholder); the member's value rides only in variables.
241 if sent.Query != `query($team: String!) { issues(filter: { team: { key: { eq: $team } } }) { nodes { identifier title } } }` {
242 t.Errorf("query mangled: %q", sent.Query)
243 }
244 if sent.Variables["team"] != "ENG" {
245 t.Errorf("variables: %v", sent.Variables)
246 }
247 // The dot-path pick root descends the GraphQL data envelope; undeclared
248 // fields (estimate) are dropped.
249 if body != `[{"identifier":"ENG-1","title":"Fix login"}]` {
250 t.Errorf("projected body: %q", body)
251 }
252}
253
254func TestExecuteAsOAuthForcesBearer(t *testing.T) {
255 // Mixed-scheme providers: the manifest's "none" scheme is for pasted
256 // keys; a credential of kind "oauth" is a Bearer token by definition
257 // (RFC 6750), so ExecuteAs must override the manifest for it — and leave
258 // "pat" and legacy (empty) kinds on the manifest's bare-token scheme.
259 m, err := Parse([]byte(graphqlManifest))
260 if err != nil {
261 t.Fatalf("graphql manifest: %v", err)
262 }
263 var auth string
264 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
265 auth = r.Header.Get("Authorization")
266 io.WriteString(w, `{"data":{"issues":{"nodes":[]}}}`)
267 }))
268 t.Cleanup(srv.Close)
269 t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL)
270
271 for kind, want := range map[string]string{
272 "oauth": "Bearer lin_oauth_tok",
273 "pat": "lin_oauth_tok",
274 "": "lin_oauth_tok",
275 } {
276 if _, err := ExecuteAs(m, "issues.list", map[string]string{"team": "ENG"}, "lin_oauth_tok", kind); err != nil {
277 t.Fatalf("ExecuteAs(kind=%q): %v", kind, err)
278 }
279 if auth != want {
280 t.Errorf("kind %q: authorization %q, want %q", kind, auth, want)
281 }
282 }
283}
284
285// pickManifest declares ops with response projections: an enveloped list
286// (DNSimple's {"data": […]} shape), a bare object with a nested dot-path
287// field (GitHub's user.login shape), and a root-only descent.
288const pickManifest = `{
289 "schema": 1,
290 "id": "testsvc",
291 "name": "TestSvc",
292 "category": "test",
293 "auth": { "kind": "pat", "base": "https://api.testsvc.example" },
294 "ops": [
295 { "id": "rows.list", "label": "Rows", "access": "read", "method": "GET", "path": "/rows",
296 "pick": { "root": "data", "fields": ["name", "user.login"] } },
297 { "id": "me.get", "label": "Me", "access": "read", "method": "GET", "path": "/me",
298 "pick": { "fields": ["login", "plan.name"] } },
299 { "id": "env.get", "label": "Envelope", "access": "read", "method": "GET", "path": "/env",
300 "pick": { "root": "data.inner" } }
301 ]
302}`
303
304func fakePickService(t *testing.T, respBody string) *Manifest {
305 t.Helper()
306 m, err := Parse([]byte(pickManifest))
307 if err != nil {
308 t.Fatalf("pick manifest: %v", err)
309 }
310 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
311 io.WriteString(w, respBody)
312 }))
313 t.Cleanup(srv.Close)
314 t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL)
315 return m
316}
317
318func TestExecutePickProjectsArrayUnderRoot(t *testing.T) {
319 m := fakePickService(t, `{"data":[
320 {"name":"a.com","state":"registered","user":{"login":"amy","id":7},"huge":"`+strings.Repeat("x", 4096)+`"},
321 {"name":"b.com","user":{"id":8}},
322 "not-an-object"
323 ]}`)
324 body, err := Execute(m, "rows.list", nil, "tk")
325 if err != nil {
326 t.Fatalf("Execute: %v", err)
327 }
328 var rows []map[string]any
329 if err := json.Unmarshal([]byte(body), &rows); err != nil {
330 t.Fatalf("projected body %q: %v", body, err)
331 }
332 // Undeclared fields dropped, nested dot-path flattened, absent field
333 // omitted, non-object element skipped.
334 if len(rows) != 2 {
335 t.Fatalf("rows: %v", rows)
336 }
337 if rows[0]["name"] != "a.com" || rows[0]["user.login"] != "amy" {
338 t.Errorf("row 0: %v", rows[0])
339 }
340 if _, present := rows[0]["state"]; present {
341 t.Errorf("undeclared field survived: %v", rows[0])
342 }
343 if _, present := rows[1]["user.login"]; present || rows[1]["name"] != "b.com" {
344 t.Errorf("row 1: %v", rows[1])
345 }
346 if strings.Contains(body, "xxxx") {
347 t.Error("unpicked bulk survived projection")
348 }
349}
350
351func TestExecutePickProjectsSingleObject(t *testing.T) {
352 m := fakePickService(t, `{"login":"octocat","plan":{"name":"pro","seats":9},"company":"GitHub"}`)
353 body, err := Execute(m, "me.get", nil, "tk")
354 if err != nil {
355 t.Fatalf("Execute: %v", err)
356 }
357 var got map[string]any
358 if err := json.Unmarshal([]byte(body), &got); err != nil {
359 t.Fatalf("projected body %q: %v", body, err)
360 }
361 if got["login"] != "octocat" || got["plan.name"] != "pro" || len(got) != 2 {
362 t.Errorf("projected: %v", got)
363 }
364}
365
366func TestExecutePickRootOnlyDescends(t *testing.T) {
367 m := fakePickService(t, `{"data":{"inner":{"whole":"thing"}}}`)
368 body, err := Execute(m, "env.get", nil, "tk")
369 if err != nil {
370 t.Fatalf("Execute: %v", err)
371 }
372 if body != `{"whole":"thing"}` {
373 t.Errorf("body: %q", body)
374 }
375}
376
377func TestExecutePickHonestErrors(t *testing.T) {
378 // A response that isn't JSON, and one missing the declared envelope: both
379 // must error (never a silent empty projection).
380 m := fakePickService(t, `<!doctype html>`)
381 if _, err := Execute(m, "rows.list", nil, "tk"); err == nil || !strings.Contains(err.Error(), "isn't JSON") {
382 t.Errorf("non-JSON response: %v", err)
383 }
384 m2 := fakePickService(t, `{"unexpected":true}`)
385 if _, err := Execute(m2, "rows.list", nil, "tk"); err == nil || !strings.Contains(err.Error(), "envelope") {
386 t.Errorf("missing envelope: %v", err)
387 }
388}
389
390func TestExecutePinsHostWithoutOverride(t *testing.T) {
391 // Without the env override the manifest base (https, real host) is used;
392 // no server listens there, so the request must fail — but it must fail
393 // on the network, not by the pin (which proves the pin passes for the
394 // manifest's own host). More importantly, a non-https base must be
395 // refused by the pin itself.
396 m, err := Parse([]byte(testManifest))
397 if err != nil {
398 t.Fatal(err)
399 }
400 m.Auth.Base = "http://api.testsvc.example" // simulate a bypassed loader
401 if _, err := Execute(m, "items.list", map[string]string{"account": "a"}, "tk"); err == nil || !strings.Contains(err.Error(), "pinned") {
402 t.Errorf("http base without override: %v", err)
403 }
404}
405