|
|
@@ -0,0 +1,291 @@
|
|
|
+package agi
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/base64"
|
|
|
+ "encoding/json"
|
|
|
+ "io"
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "net/url"
|
|
|
+ "strings"
|
|
|
+ "testing"
|
|
|
+
|
|
|
+ "github.com/robertkrimen/otto"
|
|
|
+ "imuslab.com/arozos/mod/agi/static"
|
|
|
+ user "imuslab.com/arozos/mod/user"
|
|
|
+)
|
|
|
+
|
|
|
+// ─── body builder ────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+func TestBuildHTTPRequestBody(t *testing.T) {
|
|
|
+ //bodyBase64 takes precedence and decodes to raw bytes
|
|
|
+ t.Run("base64", func(t *testing.T) {
|
|
|
+ raw := []byte{0x00, 0x01, 0x02, 0xff}
|
|
|
+ opt := httpRequestOptions{BodyBase64: base64.StdEncoding.EncodeToString(raw)}
|
|
|
+ r, ct, err := buildHTTPRequestBody(opt)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if ct != "application/octet-stream" {
|
|
|
+ t.Errorf("unexpected content-type: %q", ct)
|
|
|
+ }
|
|
|
+ got, _ := io.ReadAll(r)
|
|
|
+ if string(got) != string(raw) {
|
|
|
+ t.Errorf("binary body not decoded correctly: %v", got)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("invalid base64", func(t *testing.T) {
|
|
|
+ _, _, err := buildHTTPRequestBody(httpRequestOptions{BodyBase64: "!!!not base64!!!"})
|
|
|
+ if err == nil {
|
|
|
+ t.Error("expected error for invalid base64 body")
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("form", func(t *testing.T) {
|
|
|
+ opt := httpRequestOptions{Form: map[string]string{"a": "1", "b": "hello world"}}
|
|
|
+ r, ct, err := buildHTTPRequestBody(opt)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if ct != "application/x-www-form-urlencoded" {
|
|
|
+ t.Errorf("unexpected content-type: %q", ct)
|
|
|
+ }
|
|
|
+ got, _ := io.ReadAll(r)
|
|
|
+ values, _ := url.ParseQuery(string(got))
|
|
|
+ if values.Get("a") != "1" || values.Get("b") != "hello world" {
|
|
|
+ t.Errorf("form not encoded correctly: %q", string(got))
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("json", func(t *testing.T) {
|
|
|
+ opt := httpRequestOptions{JSON: json.RawMessage(`{"x":1}`)}
|
|
|
+ r, ct, err := buildHTTPRequestBody(opt)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if ct != "application/json" {
|
|
|
+ t.Errorf("unexpected content-type: %q", ct)
|
|
|
+ }
|
|
|
+ got, _ := io.ReadAll(r)
|
|
|
+ if strings.TrimSpace(string(got)) != `{"x":1}` {
|
|
|
+ t.Errorf("json body mismatch: %q", string(got))
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("raw", func(t *testing.T) {
|
|
|
+ opt := httpRequestOptions{Body: "plain text"}
|
|
|
+ r, ct, err := buildHTTPRequestBody(opt)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ if ct != "" {
|
|
|
+ t.Errorf("raw body should carry no default content-type, got %q", ct)
|
|
|
+ }
|
|
|
+ got, _ := io.ReadAll(r)
|
|
|
+ if string(got) != "plain text" {
|
|
|
+ t.Errorf("raw body mismatch: %q", string(got))
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("empty", func(t *testing.T) {
|
|
|
+ r, ct, err := buildHTTPRequestBody(httpRequestOptions{})
|
|
|
+ if err != nil || r != nil || ct != "" {
|
|
|
+ t.Errorf("empty options should yield no body, got r=%v ct=%q err=%v", r, ct, err)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ─── request execution ───────────────────────────────────────────────────────
|
|
|
+
|
|
|
+func TestDoHTTPRequestMethodHeadersAndBody(t *testing.T) {
|
|
|
+ var gotMethod, gotHeader, gotBody, gotContentType string
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ gotMethod = r.Method
|
|
|
+ gotHeader = r.Header.Get("X-Custom")
|
|
|
+ gotContentType = r.Header.Get("Content-Type")
|
|
|
+ b, _ := io.ReadAll(r.Body)
|
|
|
+ gotBody = string(b)
|
|
|
+ w.Header().Set("X-Reply", "pong")
|
|
|
+ w.WriteHeader(201)
|
|
|
+ io.WriteString(w, "created")
|
|
|
+ }))
|
|
|
+ defer srv.Close()
|
|
|
+
|
|
|
+ resp := doHTTPRequest(httpRequestOptions{
|
|
|
+ URL: srv.URL,
|
|
|
+ Method: "put",
|
|
|
+ Headers: map[string]string{"X-Custom": "yes"},
|
|
|
+ Body: "the payload",
|
|
|
+ })
|
|
|
+
|
|
|
+ if resp.Error != "" {
|
|
|
+ t.Fatalf("unexpected error: %s", resp.Error)
|
|
|
+ }
|
|
|
+ if gotMethod != "PUT" {
|
|
|
+ t.Errorf("method not applied/uppercased, got %q", gotMethod)
|
|
|
+ }
|
|
|
+ if gotHeader != "yes" {
|
|
|
+ t.Errorf("custom header not sent, got %q", gotHeader)
|
|
|
+ }
|
|
|
+ if gotBody != "the payload" {
|
|
|
+ t.Errorf("body not sent, got %q", gotBody)
|
|
|
+ }
|
|
|
+ if gotContentType != "" {
|
|
|
+ t.Errorf("raw body should not set a content-type, got %q", gotContentType)
|
|
|
+ }
|
|
|
+ if resp.Status != 201 || !resp.Ok {
|
|
|
+ t.Errorf("status/ok mismatch: status=%d ok=%v", resp.Status, resp.Ok)
|
|
|
+ }
|
|
|
+ if resp.Body != "created" {
|
|
|
+ t.Errorf("response body mismatch: %q", resp.Body)
|
|
|
+ }
|
|
|
+ if len(resp.Headers["X-Reply"]) == 0 || resp.Headers["X-Reply"][0] != "pong" {
|
|
|
+ t.Errorf("response headers not captured: %v", resp.Headers)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestDoHTTPRequestFormAndContentTypeOverride(t *testing.T) {
|
|
|
+ var gotContentType, gotBody string
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ gotContentType = r.Header.Get("Content-Type")
|
|
|
+ b, _ := io.ReadAll(r.Body)
|
|
|
+ gotBody = string(b)
|
|
|
+ io.WriteString(w, "ok")
|
|
|
+ }))
|
|
|
+ defer srv.Close()
|
|
|
+
|
|
|
+ //Form body sets the urlencoded content type by default.
|
|
|
+ resp := doHTTPRequest(httpRequestOptions{URL: srv.URL, Method: "POST", Form: map[string]string{"k": "v"}})
|
|
|
+ if resp.Error != "" {
|
|
|
+ t.Fatalf("unexpected error: %s", resp.Error)
|
|
|
+ }
|
|
|
+ if gotContentType != "application/x-www-form-urlencoded" {
|
|
|
+ t.Errorf("form content-type not defaulted, got %q", gotContentType)
|
|
|
+ }
|
|
|
+ if values, _ := url.ParseQuery(gotBody); values.Get("k") != "v" {
|
|
|
+ t.Errorf("form body mismatch: %q", gotBody)
|
|
|
+ }
|
|
|
+
|
|
|
+ //Explicit contentType option overrides the default.
|
|
|
+ doHTTPRequest(httpRequestOptions{URL: srv.URL, Method: "POST", Body: "x", ContentType: "text/csv"})
|
|
|
+ if gotContentType != "text/csv" {
|
|
|
+ t.Errorf("contentType override not applied, got %q", gotContentType)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestDoHTTPRequestBinaryResponse(t *testing.T) {
|
|
|
+ raw := []byte{0x10, 0x20, 0x30, 0xff, 0x00}
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ w.Write(raw)
|
|
|
+ }))
|
|
|
+ defer srv.Close()
|
|
|
+
|
|
|
+ resp := doHTTPRequest(httpRequestOptions{URL: srv.URL, ResponseType: "base64"})
|
|
|
+ if resp.Error != "" {
|
|
|
+ t.Fatalf("unexpected error: %s", resp.Error)
|
|
|
+ }
|
|
|
+ decoded, err := base64.StdEncoding.DecodeString(resp.Body)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("response body was not valid base64: %v", err)
|
|
|
+ }
|
|
|
+ if string(decoded) != string(raw) {
|
|
|
+ t.Errorf("binary response mismatch: %v", decoded)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestDoHTTPRequestBasicAuth(t *testing.T) {
|
|
|
+ var gotUser, gotPass string
|
|
|
+ var ok bool
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ gotUser, gotPass, ok = r.BasicAuth()
|
|
|
+ io.WriteString(w, "ok")
|
|
|
+ }))
|
|
|
+ defer srv.Close()
|
|
|
+
|
|
|
+ doHTTPRequest(httpRequestOptions{URL: srv.URL, Username: "alice", Password: "s3cr3t"})
|
|
|
+ if !ok || gotUser != "alice" || gotPass != "s3cr3t" {
|
|
|
+ t.Errorf("basic auth not applied: user=%q pass=%q ok=%v", gotUser, gotPass, ok)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestDoHTTPRequestNoFollowRedirect(t *testing.T) {
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ if r.URL.Path == "/start" {
|
|
|
+ http.Redirect(w, r, "/dest", http.StatusFound)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ io.WriteString(w, "final")
|
|
|
+ }))
|
|
|
+ defer srv.Close()
|
|
|
+
|
|
|
+ follow := false
|
|
|
+ resp := doHTTPRequest(httpRequestOptions{URL: srv.URL + "/start", FollowRedirect: &follow})
|
|
|
+ if resp.Error != "" {
|
|
|
+ t.Fatalf("unexpected error: %s", resp.Error)
|
|
|
+ }
|
|
|
+ if resp.Status != http.StatusFound {
|
|
|
+ t.Errorf("expected 302 when not following redirect, got %d", resp.Status)
|
|
|
+ }
|
|
|
+ if len(resp.Headers["Location"]) == 0 || !strings.HasSuffix(resp.Headers["Location"][0], "/dest") {
|
|
|
+ t.Errorf("expected Location header, got %v", resp.Headers)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestDoHTTPRequestMissingURL(t *testing.T) {
|
|
|
+ resp := doHTTPRequest(httpRequestOptions{})
|
|
|
+ if resp.Error == "" {
|
|
|
+ t.Error("expected an error when url is missing")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── JS object exposure ─────────────────────────────────────────────────────
|
|
|
+
|
|
|
+func TestInjectHTTPLib_JSObjectExposed(t *testing.T) {
|
|
|
+ g := minimalGateway()
|
|
|
+ vm := otto.New()
|
|
|
+ payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
|
|
|
+ g.injectHTTPFunctions(payload)
|
|
|
+
|
|
|
+ for _, method := range []string{"request", "get", "post", "put", "patch", "delete", "postForm", "postJSON", "head", "download", "getb64", "getCode", "redirect"} {
|
|
|
+ val, err := vm.Run(`typeof http.` + method)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("evaluating http.%s: %v", method, err)
|
|
|
+ }
|
|
|
+ s, _ := val.ToString()
|
|
|
+ if s != "function" {
|
|
|
+ t.Errorf("http.%s should be a function, got %q", method, s)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestHTTPRequestFromVM(t *testing.T) {
|
|
|
+ var gotMethod, gotBody string
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ gotMethod = r.Method
|
|
|
+ b, _ := io.ReadAll(r.Body)
|
|
|
+ gotBody = string(b)
|
|
|
+ io.WriteString(w, "hello vm")
|
|
|
+ }))
|
|
|
+ defer srv.Close()
|
|
|
+
|
|
|
+ g := minimalGateway()
|
|
|
+ vm := otto.New()
|
|
|
+ payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
|
|
|
+ g.injectHTTPFunctions(payload)
|
|
|
+
|
|
|
+ val, err := vm.Run(`
|
|
|
+ var resp = http.request({url: "` + srv.URL + `", method: "POST", body: "vm-body"});
|
|
|
+ resp.status + "|" + resp.ok + "|" + resp.body;
|
|
|
+ `)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("vm run error: %v", err)
|
|
|
+ }
|
|
|
+ out, _ := val.ToString()
|
|
|
+ if out != "200|true|hello vm" {
|
|
|
+ t.Errorf("unexpected VM response object: %q", out)
|
|
|
+ }
|
|
|
+ if gotMethod != "POST" || gotBody != "vm-body" {
|
|
|
+ t.Errorf("request not sent as expected: method=%q body=%q", gotMethod, gotBody)
|
|
|
+ }
|
|
|
+}
|