Skip to content
quarry Artifact [f546b87150]
Login

Artifact [f546b87150]

Artifact f546b871500ecad53f526c50c35fd47514431300de87539e3c3075a74a16d5fc:


package api

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/ops"
)

type routerIn struct {
	Repo  string `json:"repo" validate:"required"`
	Limit int    `json:"limit" validate:"min=0,max=100"`
}

type routerOut struct {
	Repo  string `json:"repo"`
	Limit int    `json:"limit"`
}

type postIn struct {
	Repo string `json:"repo" validate:"required"`
	Body string `json:"body" validate:"required"`
}

type postOut struct {
	Echo string `json:"echo"`
}

func routerRegistry(t *testing.T) *ops.Registry {
	t.Helper()
	r := ops.NewRegistry()
	if err := ops.Register(r, ops.Op[routerIn, routerOut]{
		Name:  "echo.get",
		Doc:   "Echo query input.",
		Kind:  ops.Query,
		Route: "GET /api/v1/r/{repo}/echo",
		Run: func(_ context.Context, in routerIn) (routerOut, error) {
			return routerOut(in), nil
		},
	}); err != nil {
		t.Fatal(err)
	}
	if err := ops.Register(r, ops.Op[postIn, postOut]{
		Name:  "echo.post",
		Doc:   "Echo body input.",
		Kind:  ops.Command,
		Route: "POST /api/v1/r/{repo}/echo",
		Run: func(_ context.Context, in postIn) (postOut, error) {
			return postOut{Echo: in.Repo + ":" + in.Body}, nil
		},
	}); err != nil {
		t.Fatal(err)
	}
	return r
}

func serve(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
	t.Helper()
	h, err := Router(routerRegistry(t), "0.0.0-test")
	if err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestGetOpPathAndQuery(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=5", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d: %s", rec.Code, rec.Body)
	}
	var out routerOut
	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
		t.Fatal(err)
	}
	if out.Repo != "sqlite" || out.Limit != 5 {
		t.Fatalf("got %+v", out)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		t.Fatalf("content-type %q", ct)
	}
}

func TestGetOpValidateFails400(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=101", nil))
	if rec.Code != 400 {
		t.Fatalf("status %d: %s", rec.Code, rec.Body)
	}
	var e map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
		t.Fatalf("error body not JSON: %s", rec.Body)
	}
	if e["error"] == nil {
		t.Fatalf("no error field: %v", e)
	}
}

func TestGetOpBadTypeFails400(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=abc", nil))
	if rec.Code != 400 {
		t.Fatalf("status %d", rec.Code)
	}
}

func TestPostOpFormBody(t *testing.T) {
	form := url.Values{"body": {"hello"}}
	req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := serve(t, req)
	if rec.Code != 200 {
		t.Fatalf("status %d: %s", rec.Code, rec.Body)
	}
	var out postOut
	json.Unmarshal(rec.Body.Bytes(), &out)
	if out.Echo != "sqlite:hello" {
		t.Fatalf("got %+v", out)
	}
}

func TestPostOpJSONBody(t *testing.T) {
	req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{"body":"hi"}`))
	req.Header.Set("Content-Type", "application/json")
	rec := serve(t, req)
	if rec.Code != 200 {
		t.Fatalf("status %d: %s", rec.Code, rec.Body)
	}
	var out postOut
	json.Unmarshal(rec.Body.Bytes(), &out)
	if out.Echo != "sqlite:hi" {
		t.Fatalf("got %+v", out)
	}
}

func TestPostOpJSONCannotOverridePathParam(t *testing.T) {
	// The path is authoritative for wildcard fields; a body value for the
	// same field must not win.
	req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{"repo":"evil","body":"x"}`))
	req.Header.Set("Content-Type", "application/json")
	rec := serve(t, req)
	var out postOut
	json.Unmarshal(rec.Body.Bytes(), &out)
	if !strings.HasPrefix(out.Echo, "sqlite:") {
		t.Fatalf("path param overridden: %+v", out)
	}
}

func TestPostOpMissingRequired400(t *testing.T) {
	req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{}`))
	req.Header.Set("Content-Type", "application/json")
	rec := serve(t, req)
	if rec.Code != 400 {
		t.Fatalf("status %d: %s", rec.Code, rec.Body)
	}
}

func TestOpenAPIDocumentServed(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/openapi.json", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	var doc map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
		t.Fatal(err)
	}
	if doc["openapi"] != "3.1.0" {
		t.Fatalf("doc = %v", doc["openapi"])
	}
}

// --- error taxonomy (tickets 020/040/070) ---

func TestNotFoundIs404WithCleanJSON(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/nomatch/echo", nil))
	_ = rec // route exists; entity checks below use the full registry
}

func TestUnmatchedAPIPathIsJSON404(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/nope", nil))
	if rec.Code != 404 {
		t.Fatalf("status %d", rec.Code)
	}
	var e map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
		t.Fatalf("not JSON: %s", rec.Body)
	}
	if !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
		t.Fatalf("content-type %q", rec.Header().Get("Content-Type"))
	}
}

func TestOpErrorIs500WithoutInternals(t *testing.T) {
	r := ops.NewRegistry()
	if err := ops.Register(r, ops.Op[routerIn, routerOut]{
		Name:  "boom.get",
		Doc:   "Always fails.",
		Kind:  ops.Query,
		Route: "GET /api/v1/r/{repo}/boom",
		Run: func(_ context.Context, _ routerIn) (routerOut, error) {
			return routerOut{}, fmt.Errorf("zitcli: cat secret/path: exit status 1")
		},
	}); err != nil {
		t.Fatal(err)
	}
	h, err := Router(r, "v")
	if err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/x/boom", nil))
	if rec.Code != 500 {
		t.Fatalf("status %d", rec.Code)
	}
	if strings.Contains(rec.Body.String(), "zitcli") || strings.Contains(rec.Body.String(), "secret") {
		t.Fatalf("internals leaked: %s", rec.Body)
	}
}

func TestNotFoundSentinelIs404(t *testing.T) {
	r := ops.NewRegistry()
	if err := ops.Register(r, ops.Op[routerIn, routerOut]{
		Name:  "gone.get",
		Doc:   "Always missing.",
		Kind:  ops.Query,
		Route: "GET /api/v1/r/{repo}/gone",
		Run: func(_ context.Context, _ routerIn) (routerOut, error) {
			return routerOut{}, fmt.Errorf("no such thing: %w", ops.ErrNotFound)
		},
	}); err != nil {
		t.Fatal(err)
	}
	h, err := Router(r, "v")
	if err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/x/gone", nil))
	if rec.Code != 404 {
		t.Fatalf("status %d: %s", rec.Code, rec.Body)
	}
	var e map[string]string
	json.Unmarshal(rec.Body.Bytes(), &e)
	if !strings.Contains(e["error"], "no such thing") {
		t.Fatalf("error body: %v", e)
	}
}

func TestValidationErrorMessageHygiene(t *testing.T) {
	rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=abc", nil))
	if rec.Code != 400 {
		t.Fatalf("status %d", rec.Code)
	}
	body := rec.Body.String()
	for _, leak := range []string{"strconv", "ParseInt", "ops:"} {
		if strings.Contains(body, leak) {
			t.Fatalf("leaked %q: %s", leak, body)
		}
	}
	if !strings.Contains(body, "must be an integer") {
		t.Fatalf("unhelpful message: %s", body)
	}
}