Some checks failed
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-runners (pull_request) Has been skipped
Add four new Starlark sandbox builtins to reduce extension friction around SQL decomposition and synchronous HTTP fan-out: - db.count(table, filters) — integer row count - db.aggregate(table, column, op, filters) — single-value aggregation - db.query_batch(queries) — up to 10 query specs in one call - http.batch(requests) — concurrent dispatch of up to 10 HTTP requests Extract buildSelectQuery helper from dbQuery for reuse by query_batch. 21 new tests (14 db, 7 http). No new permissions or schema changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
686 lines
20 KiB
Go
686 lines
20 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.starlark.net/starlark"
|
|
)
|
|
|
|
// ─── isPublicIP ─────────────────────────────
|
|
|
|
func TestIsPublicIP(t *testing.T) {
|
|
tests := []struct {
|
|
ip string
|
|
public bool
|
|
}{
|
|
// Public
|
|
{"8.8.8.8", true},
|
|
{"1.1.1.1", true},
|
|
{"93.184.216.34", true},
|
|
{"2607:f8b0:4004:800::200e", true}, // google IPv6
|
|
|
|
// Loopback
|
|
{"127.0.0.1", false},
|
|
{"127.0.0.2", false},
|
|
{"::1", false},
|
|
|
|
// Private RFC1918
|
|
{"10.0.0.1", false},
|
|
{"10.255.255.255", false},
|
|
{"172.16.0.1", false},
|
|
{"172.31.255.255", false},
|
|
{"192.168.0.1", false},
|
|
{"192.168.1.100", false},
|
|
|
|
// Link-local
|
|
{"169.254.0.1", false},
|
|
{"169.254.169.254", false}, // AWS metadata
|
|
{"fe80::1", false},
|
|
|
|
// Unspecified
|
|
{"0.0.0.0", false},
|
|
{"::", false},
|
|
|
|
// Multicast
|
|
{"224.0.0.1", false},
|
|
{"ff02::1", false},
|
|
|
|
// Private IPv6
|
|
{"fc00::1", false},
|
|
{"fd00::1", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
ip := net.ParseIP(tt.ip)
|
|
if ip == nil {
|
|
t.Fatalf("failed to parse test IP %q", tt.ip)
|
|
}
|
|
got := isPublicIP(ip)
|
|
if got != tt.public {
|
|
t.Errorf("isPublicIP(%s) = %v, want %v", tt.ip, got, tt.public)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── ParseNetworkAccess ─────────────────────
|
|
|
|
func TestParseNetworkAccess(t *testing.T) {
|
|
t.Run("empty manifest", func(t *testing.T) {
|
|
cfg := ParseNetworkAccess(map[string]any{})
|
|
if len(cfg.AllowDomains) != 0 || len(cfg.BlockDomains) != 0 {
|
|
t.Errorf("expected empty config, got allow=%v block=%v", cfg.AllowDomains, cfg.BlockDomains)
|
|
}
|
|
})
|
|
|
|
t.Run("allow list", func(t *testing.T) {
|
|
cfg := ParseNetworkAccess(map[string]any{
|
|
"network_access": map[string]any{
|
|
"allow": []any{"API.Example.COM", "hooks.slack.com"},
|
|
},
|
|
})
|
|
if len(cfg.AllowDomains) != 2 {
|
|
t.Fatalf("expected 2 allow domains, got %d", len(cfg.AllowDomains))
|
|
}
|
|
if cfg.AllowDomains[0] != "api.example.com" {
|
|
t.Errorf("allow[0] = %q, want %q", cfg.AllowDomains[0], "api.example.com")
|
|
}
|
|
})
|
|
|
|
t.Run("block list", func(t *testing.T) {
|
|
cfg := ParseNetworkAccess(map[string]any{
|
|
"network_access": map[string]any{
|
|
"block": []any{"evil.com"},
|
|
},
|
|
})
|
|
if len(cfg.BlockDomains) != 1 || cfg.BlockDomains[0] != "evil.com" {
|
|
t.Errorf("expected block=[evil.com], got %v", cfg.BlockDomains)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed network_access ignored", func(t *testing.T) {
|
|
cfg := ParseNetworkAccess(map[string]any{
|
|
"network_access": "not a map",
|
|
})
|
|
if len(cfg.AllowDomains) != 0 {
|
|
t.Errorf("expected empty allow, got %v", cfg.AllowDomains)
|
|
}
|
|
})
|
|
|
|
t.Run("string slice variant", func(t *testing.T) {
|
|
cfg := ParseNetworkAccess(map[string]any{
|
|
"network_access": map[string]any{
|
|
"allow": []string{"a.com", "b.com"},
|
|
},
|
|
})
|
|
if len(cfg.AllowDomains) != 2 {
|
|
t.Errorf("expected 2 allow domains, got %d", len(cfg.AllowDomains))
|
|
}
|
|
})
|
|
|
|
t.Run("empty strings filtered", func(t *testing.T) {
|
|
cfg := ParseNetworkAccess(map[string]any{
|
|
"network_access": map[string]any{
|
|
"allow": []any{"", "ok.com", ""},
|
|
},
|
|
})
|
|
if len(cfg.AllowDomains) != 1 || cfg.AllowDomains[0] != "ok.com" {
|
|
t.Errorf("expected [ok.com], got %v", cfg.AllowDomains)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── Access Control ─────────────────────────
|
|
|
|
func TestAccessControl_Allowlist(t *testing.T) {
|
|
ac := &accessControl{cfg: HTTPModuleConfig{
|
|
AllowDomains: []string{"api.example.com", "hooks.slack.com"},
|
|
}}
|
|
|
|
if err := ac.checkDomain("api.example.com"); err != nil {
|
|
t.Errorf("allowed domain rejected: %v", err)
|
|
}
|
|
if err := ac.checkDomain("hooks.slack.com"); err != nil {
|
|
t.Errorf("allowed domain rejected: %v", err)
|
|
}
|
|
if err := ac.checkDomain("evil.com"); err == nil {
|
|
t.Error("unlisted domain should be rejected in allowlist mode")
|
|
}
|
|
if err := ac.checkDomain("API.EXAMPLE.COM"); err != nil {
|
|
t.Errorf("case-insensitive match should pass: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAccessControl_Blocklist(t *testing.T) {
|
|
ac := &accessControl{cfg: HTTPModuleConfig{
|
|
BlockDomains: []string{"evil.com", "malware.org"},
|
|
}}
|
|
|
|
if err := ac.checkDomain("api.example.com"); err != nil {
|
|
t.Errorf("unblocked domain rejected: %v", err)
|
|
}
|
|
if err := ac.checkDomain("evil.com"); err == nil {
|
|
t.Error("blocked domain should be rejected")
|
|
}
|
|
if err := ac.checkDomain("malware.org"); err == nil {
|
|
t.Error("blocked domain should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestAccessControl_NoPolicy(t *testing.T) {
|
|
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
|
|
|
if err := ac.checkDomain("anything.com"); err != nil {
|
|
t.Errorf("no-policy mode should allow all public domains: %v", err)
|
|
}
|
|
}
|
|
|
|
// ─── dictToStringMap ────────────────────────
|
|
|
|
func TestDictToStringMap(t *testing.T) {
|
|
t.Run("nil dict", func(t *testing.T) {
|
|
m := dictToStringMap(nil)
|
|
if m != nil {
|
|
t.Errorf("nil input should return nil, got %v", m)
|
|
}
|
|
})
|
|
|
|
t.Run("valid dict", func(t *testing.T) {
|
|
d := starlark.NewDict(2)
|
|
_ = d.SetKey(starlark.String("Content-Type"), starlark.String("application/json"))
|
|
_ = d.SetKey(starlark.String("X-Token"), starlark.String("abc"))
|
|
m := dictToStringMap(d)
|
|
if m["Content-Type"] != "application/json" {
|
|
t.Errorf("Content-Type = %q", m["Content-Type"])
|
|
}
|
|
if m["X-Token"] != "abc" {
|
|
t.Errorf("X-Token = %q", m["X-Token"])
|
|
}
|
|
})
|
|
|
|
t.Run("non-string values skipped", func(t *testing.T) {
|
|
d := starlark.NewDict(2)
|
|
_ = d.SetKey(starlark.String("ok"), starlark.String("yes"))
|
|
_ = d.SetKey(starlark.String("bad"), starlark.MakeInt(42))
|
|
m := dictToStringMap(d)
|
|
if len(m) != 1 {
|
|
t.Errorf("expected 1 entry (non-string skipped), got %d", len(m))
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── buildResponseDict ──────────────────────
|
|
|
|
func TestBuildResponseDict(t *testing.T) {
|
|
h := http.Header{}
|
|
h.Set("Content-Type", "application/json")
|
|
h.Set("X-Request-Id", "abc-123")
|
|
|
|
val, err := buildResponseDict(200, h, `{"ok":true}`)
|
|
if err != nil {
|
|
t.Fatalf("buildResponseDict: %v", err)
|
|
}
|
|
|
|
d, ok := val.(*starlark.Dict)
|
|
if !ok {
|
|
t.Fatalf("expected *starlark.Dict, got %T", val)
|
|
}
|
|
|
|
// Status
|
|
statusVal, found, _ := d.Get(starlark.String("status"))
|
|
if !found {
|
|
t.Fatal("missing 'status' key")
|
|
}
|
|
statusInt, _ := starlark.AsInt32(statusVal)
|
|
if statusInt != 200 {
|
|
t.Errorf("status = %d, want 200", statusInt)
|
|
}
|
|
|
|
// Body
|
|
bodyVal, found, _ := d.Get(starlark.String("body"))
|
|
if !found {
|
|
t.Fatal("missing 'body' key")
|
|
}
|
|
bodyStr, _ := starlark.AsString(bodyVal)
|
|
if bodyStr != `{"ok":true}` {
|
|
t.Errorf("body = %q", bodyStr)
|
|
}
|
|
|
|
// Headers
|
|
hdrsVal, found, _ := d.Get(starlark.String("headers"))
|
|
if !found {
|
|
t.Fatal("missing 'headers' key")
|
|
}
|
|
hdrs, ok := hdrsVal.(*starlark.Dict)
|
|
if !ok {
|
|
t.Fatalf("headers is %T, want *Dict", hdrsVal)
|
|
}
|
|
ctVal, found, _ := hdrs.Get(starlark.String("content-type"))
|
|
if !found {
|
|
t.Fatal("missing content-type header")
|
|
}
|
|
ct, _ := starlark.AsString(ctVal)
|
|
if ct != "application/json" {
|
|
t.Errorf("content-type = %q", ct)
|
|
}
|
|
}
|
|
|
|
// ─── Integration: HTTP GET against httptest ──
|
|
|
|
func TestHTTPModule_GET(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
t.Errorf("expected GET, got %s", r.Method)
|
|
}
|
|
if r.Header.Get("X-Custom") != "hello" {
|
|
t.Errorf("missing custom header")
|
|
}
|
|
w.Header().Set("X-Test", "passed")
|
|
w.WriteHeader(200)
|
|
fmt.Fprint(w, `{"result":"ok"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Extract host for allowlist
|
|
host := strings.TrimPrefix(srv.URL, "http://")
|
|
hostOnly := strings.Split(host, ":")[0]
|
|
_ = hostOnly // httptest uses 127.0.0.1 which is blocked by SSRF
|
|
|
|
// For testing, we need to use the module without SSRF checks
|
|
// since httptest binds to 127.0.0.1. Test the module builder
|
|
// API by calling executeHTTPRequest with a permissive client.
|
|
// SSRF checks are tested separately via isPublicIP tests.
|
|
resp, err := executeHTTPRequest(
|
|
context.Background(),
|
|
&accessControl{cfg: HTTPModuleConfig{}},
|
|
"GET", srv.URL, "",
|
|
map[string]string{"X-Custom": "hello"},
|
|
5*time.Second,
|
|
httpMaxResponseBody,
|
|
)
|
|
// This will fail with SSRF block because httptest is 127.0.0.1.
|
|
// That's the correct behavior — verify we get the SSRF error.
|
|
if err == nil {
|
|
// If it somehow succeeded (e.g., test env allows loopback), check response
|
|
d := resp.(*starlark.Dict)
|
|
statusVal, _, _ := d.Get(starlark.String("status"))
|
|
statusInt, _ := starlark.AsInt32(statusVal)
|
|
if statusInt != 200 {
|
|
t.Errorf("status = %d, want 200", statusInt)
|
|
}
|
|
} else if !strings.Contains(err.Error(), "ssrf") && !strings.Contains(err.Error(), "private") {
|
|
t.Errorf("expected SSRF error for loopback, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_SSRFBlocked verifies that private IPs are blocked
|
|
// even when disguised as valid URLs.
|
|
func TestHTTPModule_SSRFBlocked(t *testing.T) {
|
|
targets := []string{
|
|
"http://127.0.0.1/",
|
|
"http://10.0.0.1/",
|
|
"http://192.168.1.1/",
|
|
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
|
"http://[::1]/",
|
|
}
|
|
|
|
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
|
|
|
for _, target := range targets {
|
|
_, err := executeHTTPRequest(
|
|
context.Background(), ac,
|
|
"GET", target, "", nil,
|
|
2*time.Second, httpMaxResponseBody,
|
|
)
|
|
if err == nil {
|
|
t.Errorf("expected SSRF block for %s", target)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_SchemeValidation verifies only http/https are allowed.
|
|
func TestHTTPModule_SchemeValidation(t *testing.T) {
|
|
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
|
|
|
schemes := []string{"ftp://example.com", "file:///etc/passwd", "gopher://evil.com"}
|
|
for _, u := range schemes {
|
|
_, err := executeHTTPRequest(
|
|
context.Background(), ac,
|
|
"GET", u, "", nil,
|
|
2*time.Second, httpMaxResponseBody,
|
|
)
|
|
if err == nil {
|
|
t.Errorf("expected scheme rejection for %s", u)
|
|
}
|
|
if !strings.Contains(err.Error(), "only http/https") {
|
|
t.Errorf("wrong error for %s: %v", u, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_DomainBlock verifies blocklist enforcement at request time.
|
|
func TestHTTPModule_DomainBlock(t *testing.T) {
|
|
ac := &accessControl{cfg: HTTPModuleConfig{
|
|
BlockDomains: []string{"evil.com"},
|
|
}}
|
|
|
|
_, err := executeHTTPRequest(
|
|
context.Background(), ac,
|
|
"GET", "https://evil.com/api", "", nil,
|
|
2*time.Second, httpMaxResponseBody,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected block for evil.com")
|
|
}
|
|
if !strings.Contains(err.Error(), "blocked") {
|
|
t.Errorf("wrong error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_AllowlistEnforcement verifies allowlist-only mode.
|
|
func TestHTTPModule_AllowlistEnforcement(t *testing.T) {
|
|
ac := &accessControl{cfg: HTTPModuleConfig{
|
|
AllowDomains: []string{"api.good.com"},
|
|
}}
|
|
|
|
_, err := executeHTTPRequest(
|
|
context.Background(), ac,
|
|
"GET", "https://api.bad.com/data", "", nil,
|
|
2*time.Second, httpMaxResponseBody,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected rejection for unlisted domain")
|
|
}
|
|
if !strings.Contains(err.Error(), "not in network_access allow list") {
|
|
t.Errorf("wrong error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_Timeout verifies context-based timeout.
|
|
func TestHTTPModule_Timeout(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(5 * time.Second)
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
|
_, err := executeHTTPRequest(
|
|
context.Background(), ac,
|
|
"GET", srv.URL, "", nil,
|
|
100*time.Millisecond, httpMaxResponseBody,
|
|
)
|
|
// Will either fail with SSRF (127.0.0.1) or timeout — both are correct.
|
|
if err == nil {
|
|
t.Error("expected error (SSRF or timeout)")
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_Starlark_Integration runs the http module through the
|
|
// Starlark interpreter to verify the builtins are properly wired.
|
|
// Starlark has no try/except — errors from builtins propagate to Go.
|
|
func TestHTTPModule_Starlark_Integration(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
|
AllowDomains: []string{"only-this.example.com"},
|
|
})
|
|
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
// Call http.get against a domain NOT in the allowlist.
|
|
// The builtin should return an error that propagates to Go.
|
|
script := `
|
|
def test():
|
|
return http.get(url="https://blocked.example.com/api")
|
|
|
|
result = test()
|
|
`
|
|
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
|
if err == nil {
|
|
t.Fatal("expected error for blocked domain, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not in network_access allow list") {
|
|
t.Errorf("expected allowlist error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_Starlark_POST verifies post binding accepts body/headers kwargs.
|
|
// Starlark has no try/except — we verify binding by checking the error is
|
|
// a network/DNS error (correct kwargs accepted) not a Starlark type error.
|
|
func TestHTTPModule_Starlark_POST(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
|
AllowDomains: []string{"only-this.example.com"},
|
|
})
|
|
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
// Call http.post with body and headers kwargs against an allowed domain.
|
|
// Will fail at DNS/connection (domain doesn't exist), but the binding
|
|
// itself must parse kwargs without error.
|
|
script := `
|
|
def test():
|
|
return http.post(url="https://only-this.example.com/api", body='{"k":"v"}', headers={"Content-Type": "application/json"})
|
|
|
|
result = test()
|
|
`
|
|
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
|
if err == nil {
|
|
t.Fatal("expected network error (domain doesn't resolve), got nil")
|
|
}
|
|
// Should be a network error (DNS lookup failure), NOT a Starlark
|
|
// argument-parsing error like "unexpected keyword argument".
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") {
|
|
t.Errorf("binding signature broken: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHTTPModule_Starlark_Request verifies the generic request binding.
|
|
func TestHTTPModule_Starlark_Request(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
|
AllowDomains: []string{"nope.invalid"},
|
|
})
|
|
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
// Call http.request with method, url, body kwargs.
|
|
// Will fail at DNS, but binding must accept kwargs correctly.
|
|
script := `
|
|
def test():
|
|
return http.request(method="PATCH", url="https://nope.invalid/x", body="data")
|
|
|
|
result = test()
|
|
`
|
|
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
|
if err == nil {
|
|
t.Fatal("expected network error, got nil")
|
|
}
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") {
|
|
t.Errorf("binding signature broken: %v", err)
|
|
}
|
|
}
|
|
|
|
// ─── http.batch ─────────────────────────────
|
|
|
|
func TestHTTPBatch_Starlark_BlockedDomains(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
|
AllowDomains: []string{"allowed.example.com"},
|
|
})
|
|
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
// Both requests target a blocked domain — should return error dicts, not throw.
|
|
script := `
|
|
results = http.batch([
|
|
{"method": "GET", "url": "https://blocked.example.com/a"},
|
|
{"method": "POST", "url": "https://blocked.example.com/b", "body": "{}", "headers": {"Content-Type": "application/json"}},
|
|
])
|
|
count = len(results)
|
|
`
|
|
result, err := sb.Exec(context.Background(), "test.star", script, modules)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
countVal, ok := result.Globals["count"].(starlark.Int)
|
|
if !ok {
|
|
t.Fatalf("count is %T, want starlark.Int", result.Globals["count"])
|
|
}
|
|
v, _ := countVal.Int64()
|
|
if v != 2 {
|
|
t.Errorf("got %d results, want 2", v)
|
|
}
|
|
|
|
// Verify each result is an error dict with status=0
|
|
results := result.Globals["results"].(*starlark.List)
|
|
for i := 0; i < results.Len(); i++ {
|
|
d, ok := results.Index(i).(*starlark.Dict)
|
|
if !ok {
|
|
t.Fatalf("result[%d] is %T, want *starlark.Dict", i, results.Index(i))
|
|
}
|
|
statusVal, found, _ := d.Get(starlark.String("status"))
|
|
if !found {
|
|
t.Fatalf("result[%d] missing 'status' key", i)
|
|
}
|
|
statusInt, _ := starlark.AsInt32(statusVal)
|
|
if statusInt != 0 {
|
|
t.Errorf("result[%d] status = %d, want 0 (error)", i, statusInt)
|
|
}
|
|
bodyVal, _, _ := d.Get(starlark.String("body"))
|
|
bodyStr, _ := starlark.AsString(bodyVal)
|
|
if !strings.Contains(bodyStr, "error:") {
|
|
t.Errorf("result[%d] body should contain 'error:', got %q", i, bodyStr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTPBatch_EmptyList(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{})
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
_, err := sb.Exec(context.Background(), "test.star", `results = http.batch([])`, modules)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty list")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBatch_TooMany(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{})
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
script := `
|
|
def run():
|
|
specs = []
|
|
for i in range(11):
|
|
specs.append({"method": "GET", "url": "https://x.com/"})
|
|
return http.batch(specs)
|
|
results = run()
|
|
`
|
|
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
|
if err == nil {
|
|
t.Fatal("expected error for >10 requests")
|
|
}
|
|
if !strings.Contains(err.Error(), "max 10") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBatch_MissingMethod(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{})
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
_, err := sb.Exec(context.Background(), "test.star",
|
|
`results = http.batch([{"url": "https://x.com/"}])`, modules)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing method")
|
|
}
|
|
if !strings.Contains(err.Error(), "method") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBatch_MissingURL(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{})
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
_, err := sb.Exec(context.Background(), "test.star",
|
|
`results = http.batch([{"method": "GET"}])`, modules)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing url")
|
|
}
|
|
if !strings.Contains(err.Error(), "url") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBatch_NotDict(t *testing.T) {
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{})
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
_, err := sb.Exec(context.Background(), "test.star",
|
|
`results = http.batch(["not a dict"])`, modules)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-dict element")
|
|
}
|
|
if !strings.Contains(err.Error(), "dict") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBatch_MixedResults(t *testing.T) {
|
|
// One allowed domain (will fail at DNS but not at allowlist), one blocked.
|
|
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
|
AllowDomains: []string{"allowed.invalid"},
|
|
})
|
|
|
|
sb := New(DefaultConfig())
|
|
modules := map[string]starlark.Value{"http": mod}
|
|
|
|
script := `
|
|
results = http.batch([
|
|
{"method": "GET", "url": "https://allowed.invalid/a"},
|
|
{"method": "GET", "url": "https://blocked.invalid/b"},
|
|
])
|
|
`
|
|
result, err := sb.Exec(context.Background(), "test.star", script, modules)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
|
|
results := result.Globals["results"].(*starlark.List)
|
|
if results.Len() != 2 {
|
|
t.Fatalf("got %d results, want 2", results.Len())
|
|
}
|
|
|
|
// Both should be error dicts (first = DNS failure, second = allowlist block)
|
|
// but neither should have caused the whole batch to fail.
|
|
for i := 0; i < 2; i++ {
|
|
d, ok := results.Index(i).(*starlark.Dict)
|
|
if !ok {
|
|
t.Fatalf("result[%d] is %T, want *starlark.Dict", i, results.Index(i))
|
|
}
|
|
statusVal, _, _ := d.Get(starlark.String("status"))
|
|
statusInt, _ := starlark.AsInt32(statusVal)
|
|
if statusInt != 0 {
|
|
t.Errorf("result[%d] expected error (status 0), got %d", i, statusInt)
|
|
}
|
|
}
|
|
}
|