internal/policy/http_test.go (view raw)
1package policy
2
3import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path"
10 "testing"
11
12 "postern/internal/db"
13
14 "go.starlark.net/starlark"
15)
16
17// jsonEqual reports whether two JSON-like Go values are semantically equal
18// by comparing their canonical JSON encodings. This tolerates numeric type
19// shifts across the round trip (e.g. float64 integer -> starlark.Int -> int64).
20func jsonEqual(a, b any) bool {
21 ja, err := json.Marshal(a)
22 if err != nil {
23 return false
24 }
25 jb, err := json.Marshal(b)
26 if err != nil {
27 return false
28 }
29 return bytes.Equal(ja, jb)
30}
31
32func TestHTTPModule(t *testing.T) {
33 posternDB, err := db.OpenDB(&db.Config{
34 DBPath: path.Join(t.TempDir(), "meta.db"),
35 })
36 if err != nil {
37 t.Fatal(err)
38 }
39
40 defer posternDB.Close()
41
42 // Create a test server
43 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44 // Check for expected headers
45 if r.Header.Get("Authorization") == "Bearer secret" {
46 w.Header().Set("Content-Type", "application/json")
47 w.WriteHeader(http.StatusOK)
48 w.Write([]byte(`{"key": "value"}`))
49 return
50 }
51
52 if r.Header.Get("Authorization") == "Basic dXNlcjpwYXNz" {
53 w.Header().Set("X-Test", "abc")
54 w.WriteHeader(http.StatusOK)
55 w.Write([]byte("success"))
56 return
57 }
58
59 if r.URL.Path == "/error" {
60 w.WriteHeader(http.StatusInternalServerError)
61 w.Write([]byte("Internal Server Error"))
62 return
63 }
64
65 if r.URL.Path == "/delay" {
66 // Sleep for 200ms to test timeout
67 // This will be caught by the 50ms timeout in the test
68 // Note: This is a test to ensure timeout works properly
69 // We'll check for this in the timeout test case
70 w.WriteHeader(http.StatusOK)
71 w.Write([]byte("delayed response"))
72 return
73 }
74
75 w.WriteHeader(http.StatusOK)
76 w.Write([]byte("ok"))
77 }))
78 defer server.Close()
79
80 policy := `
81def init():
82 return None
83
84def test_post_json():
85 res = http.post("` + server.URL + `") \
86 .bearer("secret") \
87 .json({"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}) \
88 .send()
89 return res.json()["key"]
90
91def test_basic_auth():
92 res = http.get("` + server.URL + `") \
93 .basic_auth("user", "pass") \
94 .header("X-Test", "abc") \
95 .send()
96 return res.text()
97
98def test_error_status():
99 res = http.get("` + server.URL + `/error") \
100 .send()
101 return res.is_ok
102
103def test_timeout():
104 # This should timeout since server delays response
105 res = http.get("` + server.URL + `/delay") \
106 .timeout(0.05) \
107 .send()
108 return res.text()
109`
110
111 policyFile := path.Join(t.TempDir(), "policy.star")
112 err = os.WriteFile(policyFile, []byte(policy), 0644)
113 if err != nil {
114 t.Fatal(err)
115 }
116
117 e, err := NewEngine(policyFile, posternDB)
118 if err != nil {
119 t.Fatal(err)
120 }
121
122 // Test 1: Chained POST JSON
123 hookVal, ok := e.globals["test_post_json"]
124 if !ok {
125 t.Fatal("test_post_json not found in engine globals")
126 }
127
128 res, err := starlark.Call(e.thread, hookVal, nil, nil)
129 if err != nil {
130 t.Fatal(err)
131 }
132
133 if want := starlark.String("value"); res != want {
134 t.Fatalf("got %s, want %s", res, want)
135 }
136
137 // Test 2: Basic Auth + Custom Headers
138 hookVal, ok = e.globals["test_basic_auth"]
139 if !ok {
140 t.Fatal("test_basic_auth not found in engine globals")
141 }
142
143 res, err = starlark.Call(e.thread, hookVal, nil, nil)
144 if err != nil {
145 t.Fatal(err)
146 }
147
148 if want := starlark.String("success"); res != want {
149 t.Fatalf("got %s, want %s", res, want)
150 }
151
152 // Test 3: Error status handling
153 hookVal, ok = e.globals["test_error_status"]
154 if !ok {
155 t.Fatal("test_error_status not found in engine globals")
156 }
157
158 res, err = starlark.Call(e.thread, hookVal, nil, nil)
159 if err != nil {
160 t.Fatal(err)
161 }
162
163 if want := starlark.Bool(false); res != want {
164 t.Fatalf("got %s, want %s", res, want)
165 }
166
167 // Test 4: Timeout (this should time out)
168 hookVal, ok = e.globals["test_timeout"]
169 if !ok {
170 t.Fatal("test_timeout not found in engine globals")
171 }
172
173 _, err = starlark.Call(e.thread, hookVal, nil, nil)
174 if err == nil {
175 t.Fatal("expected timeout to raise an error")
176 }
177}
178
179func TestStarlarkJSONRoundTrip(t *testing.T) {
180 tests := []struct {
181 name string
182 goValue any
183 }{
184 {"string", "hello"},
185 {"empty string", ""},
186 {"bool true", true},
187 {"bool false", false},
188 {"nil", nil},
189 {"float", 3.14},
190 {"integer as float", 42.0},
191 {"negative integer", -7.0},
192 {"list of strings", []any{"a", "b", "c"}},
193 {"empty list", []any{}},
194 {"map", map[string]any{"name": "Alice", "active": true}},
195 {"empty map", map[string]any{}},
196 {"nested map", map[string]any{"list": []any{"x", "y"}, "num": 3.14, "nested": map[string]any{"k": "v"}}},
197 {"list of mixed", []any{"x", 3.14, true, nil}},
198 {"list of lists", []any{[]any{1.0, 2.0}, []any{"a"}}},
199 {"deeply nested", map[string]any{"users": []any{
200 map[string]any{"name": "a", "age": 30.0},
201 map[string]any{"name": "b", "age": 25.0},
202 }}},
203 }
204
205 for _, tt := range tests {
206 t.Run(tt.name, func(t *testing.T) {
207 star, err := jsonToStarlark(tt.goValue)
208 if err != nil {
209 t.Fatalf("jsonToStarlark() unexpected error: %v", err)
210 }
211
212 got, err := starlarkToJSON(star)
213 if err != nil {
214 t.Fatalf("starlarkToJSON() unexpected error: %v", err)
215 }
216
217 if !jsonEqual(got, tt.goValue) {
218 t.Errorf("round trip mismatch: got %#v, want %#v", got, tt.goValue)
219 }
220 })
221 }
222}
223
224func TestStarlarkToJSONErrors(t *testing.T) {
225 // Build a dict with a non-string key to trigger an error.
226 badDict := starlark.NewDict(1)
227 _ = badDict.SetKey(starlark.MakeInt(1), starlark.String("v"))
228
229 tests := []struct {
230 name string
231 input starlark.Value
232 }{
233 {"unsupported type", starlark.NewSet(0)},
234 {"dict with non-string key", badDict},
235 }
236
237 for _, tt := range tests {
238 t.Run(tt.name, func(t *testing.T) {
239 _, err := starlarkToJSON(tt.input)
240 if err == nil {
241 t.Fatal("starlarkToJSON() expected error, got nil")
242 }
243 })
244 }
245}
246
247func TestJSONToStarlarkErrors(t *testing.T) {
248 tests := []struct {
249 name string
250 input any
251 }{
252 {"unsupported int", 5},
253 {"unsupported struct", struct{ X int }{X: 1}},
254 {"unsupported byte slice", []byte("raw")},
255 }
256
257 for _, tt := range tests {
258 t.Run(tt.name, func(t *testing.T) {
259 _, err := jsonToStarlark(tt.input)
260 if err == nil {
261 t.Fatal("jsonToStarlark() expected error, got nil")
262 }
263 })
264 }
265}