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