package integrations import ( "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" ) // testManifest declares one service with a read op (path + query args), a // JSON write op with a nested body and an optional arg, and a form write op — // covering every encoding the executor supports. const testManifest = `{ "schema": 1, "id": "testsvc", "name": "TestSvc", "category": "test", "auth": { "kind": "pat", "base": "https://api.testsvc.example", "authorization": "Token", "headers": { "X-Api-Version": "7" } }, "ops": [ { "id": "items.list", "label": "List", "access": "read", "method": "GET", "path": "/v1/{account}/items?limit=10&q={q}", "args": { "account": { "required": true }, "q": {} } }, { "id": "item.create", "label": "Create", "access": "write", "method": "POST", "path": "/v1/items", "args": { "title": { "required": true }, "note": {} }, "body_json": { "props": { "title": "{title}", "note": "{note}", "kind": "widget" } }, "confirm": "Create {title}?" }, { "id": "item.refund", "label": "Refund", "access": "write", "method": "POST", "path": "/v1/refunds", "args": { "charge": { "required": true } }, "body_form": { "charge": "{charge}", "reason": "requested" }, "encoding": "form", "confirm": "Refund {charge}?" } ] }` // captured is what the fake service saw for one request. type captured struct { method, uri, auth, accept, contentType string body []byte } // fakeService stands up an httptest server, points the executor's env // override at it (the LCHAT_DNSIMPLE_BASE pattern), and records each request. func fakeService(t *testing.T, status int, respBody string) (*Manifest, *captured) { t.Helper() m, err := Parse([]byte(testManifest)) if err != nil { t.Fatalf("test manifest: %v", err) } got := &captured{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.method = r.Method got.uri = r.URL.RequestURI() got.auth = r.Header.Get("Authorization") got.accept = r.Header.Get("Accept") got.contentType = r.Header.Get("Content-Type") got.body, _ = io.ReadAll(r.Body) w.WriteHeader(status) io.WriteString(w, respBody) })) t.Cleanup(srv.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL) return m, got } func TestExecuteGetSubstitutesPathAndQuery(t *testing.T) { m, got := fakeService(t, 200, `{"ok":true}`) body, err := Execute(m, "items.list", map[string]string{"account": "acct 1", "q": "a&b"}, "tok123") if err != nil { t.Fatalf("Execute: %v", err) } if body != `{"ok":true}` { t.Errorf("body: %q", body) } // Path slot path-escaped, query slot query-escaped. if got.uri != "/v1/acct%201/items?limit=10&q=a%26b" { t.Errorf("uri: %q", got.uri) } if got.auth != "Token tok123" { t.Errorf("authorization: %q", got.auth) } if got.accept != "application/json" { t.Errorf("accept: %q", got.accept) } if got.method != "GET" { t.Errorf("method: %q", got.method) } } func TestExecutePostJSONNestedBodyDropsAbsentOptionals(t *testing.T) { m, got := fakeService(t, 201, `{"id":1}`) // "note" is optional and absent: its pure-placeholder leaf must be // DROPPED, not sent as "". if _, err := Execute(m, "item.create", map[string]string{"title": "A widget"}, "tk"); err != nil { t.Fatalf("Execute: %v", err) } if got.contentType != "application/json" { t.Errorf("content-type: %q", got.contentType) } var sent struct { Props map[string]any `json:"props"` } if err := json.Unmarshal(got.body, &sent); err != nil { t.Fatalf("body %q: %v", got.body, err) } if sent.Props["title"] != "A widget" || sent.Props["kind"] != "widget" { t.Errorf("props: %v", sent.Props) } if _, present := sent.Props["note"]; present { t.Errorf("absent optional arg was sent: %v", sent.Props) } } func TestExecutePostForm(t *testing.T) { m, got := fakeService(t, 200, `{}`) if _, err := Execute(m, "item.refund", map[string]string{"charge": "ch_1&x=2"}, "tk"); err != nil { t.Fatalf("Execute: %v", err) } if got.contentType != "application/x-www-form-urlencoded" { t.Errorf("content-type: %q", got.contentType) } // url.Values encoding must contain the value, delimiters escaped. if !strings.Contains(string(got.body), "charge=ch_1%26x%3D2") || !strings.Contains(string(got.body), "reason=requested") { t.Errorf("form body: %q", got.body) } } func TestExecuteArgValidation(t *testing.T) { m, _ := fakeService(t, 200, `{}`) if _, err := Execute(m, "items.list", map[string]string{}, "tk"); err == nil || !strings.Contains(err.Error(), "account") { t.Errorf("missing required arg: %v", err) } if _, err := Execute(m, "items.list", map[string]string{"account": "a", "bogus": "x"}, "tk"); err == nil || !strings.Contains(err.Error(), "bogus") { t.Errorf("undeclared arg: %v", err) } if _, err := Execute(m, "items.list", map[string]string{"account": "a\x00b"}, "tk"); err == nil { t.Error("control chars accepted") } if _, err := Execute(m, "items.list", map[string]string{"account": strings.Repeat("a", 3000)}, "tk"); err == nil { t.Error("oversized value accepted") } } func TestExecuteValueCannotBreakOutOfItsSlot(t *testing.T) { m, got := fakeService(t, 200, `{}`) // A hostile value tries path traversal + query injection; it must arrive // fully escaped inside its one path segment. if _, err := Execute(m, "items.list", map[string]string{"account": "../admin?x=1&y=2#f"}, "tk"); err != nil { t.Fatalf("Execute: %v", err) } if !strings.HasPrefix(got.uri, "/v1/..%2Fadmin%3Fx%3D1%26y%3D2%23f/items?") { t.Errorf("hostile value not contained: %q", got.uri) } // No un-escaped delimiter appears between the template's own slashes. seg := strings.TrimPrefix(got.uri, "/v1/") seg = seg[:strings.Index(seg, "/items")] for _, c := range []string{"/", "?", "#", "&"} { if strings.Contains(seg, c) { t.Errorf("un-escaped %q in substituted segment %q", c, seg) } } } func TestExecuteNon2xxSurfacesStatusAndBody(t *testing.T) { m, _ := fakeService(t, 422, `{"message":"Validation Failed"}`) _, err := Execute(m, "items.list", map[string]string{"account": "a"}, "tk") if err == nil || !strings.Contains(err.Error(), "testsvc.items.list: status 422") || !strings.Contains(err.Error(), "Validation Failed") { t.Errorf("non-2xx error: %v", err) } } func TestExecuteUnknownOp(t *testing.T) { m, _ := fakeService(t, 200, `{}`) if _, err := Execute(m, "nope.nothing", nil, "tk"); err == nil || !strings.Contains(err.Error(), "unknown op") { t.Errorf("unknown op: %v", err) } } // graphqlManifest pins the Linear pattern: a bare-token Authorization header // ("none" — Linear rejects a Bearer prefix on personal API keys) and GraphQL // ops whose query text is full of braces that must pass through the // placeholder machinery untouched, member input riding only in the variables // map. Every brace in query text keeps a space inside (`{ nodes ` not // `{nodes`) so nothing can read as a `{arg}` placeholder. const graphqlManifest = `{ "schema": 1, "id": "testsvc", "name": "TestSvc", "category": "test", "auth": { "kind": "pat", "base": "https://api.testsvc.example", "authorization": "none" }, "ops": [ { "id": "issues.list", "label": "Issues", "access": "read", "method": "POST", "path": "/graphql", "args": { "team": { "required": true } }, "body_json": { "query": "query($team: String!) { issues(filter: { team: { key: { eq: $team } } }) { nodes { identifier title } } }", "variables": { "team": "{team}" } }, "pick": { "root": "data.issues.nodes", "fields": ["identifier", "title"] } } ] }` func TestExecuteGraphQLBareTokenAndBracePassthrough(t *testing.T) { m, err := Parse([]byte(graphqlManifest)) if err != nil { t.Fatalf("graphql manifest: %v", err) } got := &captured{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.auth = r.Header.Get("Authorization") got.body, _ = io.ReadAll(r.Body) io.WriteString(w, `{"data":{"issues":{"nodes":[{"identifier":"ENG-1","title":"Fix login","estimate":3}]}}}`) })) t.Cleanup(srv.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL) body, err := Execute(m, "issues.list", map[string]string{"team": "ENG"}, "lin_api_tok") if err != nil { t.Fatalf("Execute: %v", err) } // "none" sends the token alone — no scheme prefix. if got.auth != "lin_api_tok" { t.Errorf("authorization: %q", got.auth) } var sent struct { Query string `json:"query"` Variables map[string]string `json:"variables"` } if err := json.Unmarshal(got.body, &sent); err != nil { t.Fatalf("body %q: %v", got.body, err) } // The query's GraphQL braces arrive byte-identical (none is a // placeholder); the member's value rides only in variables. if sent.Query != `query($team: String!) { issues(filter: { team: { key: { eq: $team } } }) { nodes { identifier title } } }` { t.Errorf("query mangled: %q", sent.Query) } if sent.Variables["team"] != "ENG" { t.Errorf("variables: %v", sent.Variables) } // The dot-path pick root descends the GraphQL data envelope; undeclared // fields (estimate) are dropped. if body != `[{"identifier":"ENG-1","title":"Fix login"}]` { t.Errorf("projected body: %q", body) } } func TestExecuteAsOAuthForcesBearer(t *testing.T) { // Mixed-scheme providers: the manifest's "none" scheme is for pasted // keys; a credential of kind "oauth" is a Bearer token by definition // (RFC 6750), so ExecuteAs must override the manifest for it — and leave // "pat" and legacy (empty) kinds on the manifest's bare-token scheme. m, err := Parse([]byte(graphqlManifest)) if err != nil { t.Fatalf("graphql manifest: %v", err) } var auth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth = r.Header.Get("Authorization") io.WriteString(w, `{"data":{"issues":{"nodes":[]}}}`) })) t.Cleanup(srv.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL) for kind, want := range map[string]string{ "oauth": "Bearer lin_oauth_tok", "pat": "lin_oauth_tok", "": "lin_oauth_tok", } { if _, err := ExecuteAs(m, "issues.list", map[string]string{"team": "ENG"}, "lin_oauth_tok", kind); err != nil { t.Fatalf("ExecuteAs(kind=%q): %v", kind, err) } if auth != want { t.Errorf("kind %q: authorization %q, want %q", kind, auth, want) } } } // pickManifest declares ops with response projections: an enveloped list // (DNSimple's {"data": […]} shape), a bare object with a nested dot-path // field (GitHub's user.login shape), and a root-only descent. const pickManifest = `{ "schema": 1, "id": "testsvc", "name": "TestSvc", "category": "test", "auth": { "kind": "pat", "base": "https://api.testsvc.example" }, "ops": [ { "id": "rows.list", "label": "Rows", "access": "read", "method": "GET", "path": "/rows", "pick": { "root": "data", "fields": ["name", "user.login"] } }, { "id": "me.get", "label": "Me", "access": "read", "method": "GET", "path": "/me", "pick": { "fields": ["login", "plan.name"] } }, { "id": "env.get", "label": "Envelope", "access": "read", "method": "GET", "path": "/env", "pick": { "root": "data.inner" } } ] }` func fakePickService(t *testing.T, respBody string) *Manifest { t.Helper() m, err := Parse([]byte(pickManifest)) if err != nil { t.Fatalf("pick manifest: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, respBody) })) t.Cleanup(srv.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_TESTSVC", srv.URL) return m } func TestExecutePickProjectsArrayUnderRoot(t *testing.T) { m := fakePickService(t, `{"data":[ {"name":"a.com","state":"registered","user":{"login":"amy","id":7},"huge":"`+strings.Repeat("x", 4096)+`"}, {"name":"b.com","user":{"id":8}}, "not-an-object" ]}`) body, err := Execute(m, "rows.list", nil, "tk") if err != nil { t.Fatalf("Execute: %v", err) } var rows []map[string]any if err := json.Unmarshal([]byte(body), &rows); err != nil { t.Fatalf("projected body %q: %v", body, err) } // Undeclared fields dropped, nested dot-path flattened, absent field // omitted, non-object element skipped. if len(rows) != 2 { t.Fatalf("rows: %v", rows) } if rows[0]["name"] != "a.com" || rows[0]["user.login"] != "amy" { t.Errorf("row 0: %v", rows[0]) } if _, present := rows[0]["state"]; present { t.Errorf("undeclared field survived: %v", rows[0]) } if _, present := rows[1]["user.login"]; present || rows[1]["name"] != "b.com" { t.Errorf("row 1: %v", rows[1]) } if strings.Contains(body, "xxxx") { t.Error("unpicked bulk survived projection") } } func TestExecutePickProjectsSingleObject(t *testing.T) { m := fakePickService(t, `{"login":"octocat","plan":{"name":"pro","seats":9},"company":"GitHub"}`) body, err := Execute(m, "me.get", nil, "tk") if err != nil { t.Fatalf("Execute: %v", err) } var got map[string]any if err := json.Unmarshal([]byte(body), &got); err != nil { t.Fatalf("projected body %q: %v", body, err) } if got["login"] != "octocat" || got["plan.name"] != "pro" || len(got) != 2 { t.Errorf("projected: %v", got) } } func TestExecutePickRootOnlyDescends(t *testing.T) { m := fakePickService(t, `{"data":{"inner":{"whole":"thing"}}}`) body, err := Execute(m, "env.get", nil, "tk") if err != nil { t.Fatalf("Execute: %v", err) } if body != `{"whole":"thing"}` { t.Errorf("body: %q", body) } } func TestExecutePickHonestErrors(t *testing.T) { // A response that isn't JSON, and one missing the declared envelope: both // must error (never a silent empty projection). m := fakePickService(t, ``) if _, err := Execute(m, "rows.list", nil, "tk"); err == nil || !strings.Contains(err.Error(), "isn't JSON") { t.Errorf("non-JSON response: %v", err) } m2 := fakePickService(t, `{"unexpected":true}`) if _, err := Execute(m2, "rows.list", nil, "tk"); err == nil || !strings.Contains(err.Error(), "envelope") { t.Errorf("missing envelope: %v", err) } } func TestExecutePinsHostWithoutOverride(t *testing.T) { // Without the env override the manifest base (https, real host) is used; // no server listens there, so the request must fail — but it must fail // on the network, not by the pin (which proves the pin passes for the // manifest's own host). More importantly, a non-https base must be // refused by the pin itself. m, err := Parse([]byte(testManifest)) if err != nil { t.Fatal(err) } m.Auth.Base = "http://api.testsvc.example" // simulate a bypassed loader if _, err := Execute(m, "items.list", map[string]string{"account": "a"}, "tk"); err == nil || !strings.Contains(err.Error(), "pinned") { t.Errorf("http base without override: %v", err) } }