package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/errlog"
"craftdesign.group/quarry/internal/oltp"
"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)
}
}
// A forge agent's token passes the gate of an Upkeep op on a repository
// it holds nothing in, and no other operator op: upkeep is the one
// thing it may do there. A token that is not an agent's, or does not
// reach the repository, is refused like anybody without the letter.
func TestAForgeAgentMayDoUpkeepAndNothingElse(t *testing.T) {
r := ops.NewRegistry()
for _, op := range []ops.Op[routerIn, routerOut]{
{Name: "fix.do", Doc: "Upkeep.", Kind: ops.Command, Auth: ops.Token, Caps: "a",
Operator: true, Upkeep: true, Route: "POST /api/v1/r/{repo}/fix",
Run: func(ctx context.Context, _ routerIn) (routerOut, error) {
if !ops.OnUpkeep(ctx) {
return routerOut{}, errors.New("not told it is upkeep")
}
return routerOut{}, nil
}},
{Name: "end.do", Doc: "A lifecycle act.", Kind: ops.Command, Auth: ops.Token, Caps: "a",
Operator: true, Route: "POST /api/v1/r/{repo}/end",
Run: func(context.Context, routerIn) (routerOut, error) { return routerOut{}, nil }},
} {
if err := ops.Register(r, op); err != nil {
t.Fatal(err)
}
}
none := func(string, string) CapSet { return oltp.CapSet("") }
h, err := RouterWithCaps(r, "v", none, nil)
if err != nil {
t.Fatal(err)
}
as := func(agent bool, reaches bool) context.Context {
return ops.WithToken(ops.WithUser(context.Background(), "zeitgeist"), ops.TokenAuth{ID: "T",
Caps: func(string) ops.Caps { return oltp.CapSet("") },
Reaches: func(string) bool { return reaches },
ForgeAgent: func() bool { return agent }})
}
code := func(ctx context.Context, path string) int {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader("{}")).WithContext(ctx))
return rec.Code
}
if c := code(as(true, true), "/api/v1/r/theirs/fix"); c != 200 {
t.Errorf("FAIL: a forge agent's upkeep was refused: %d", c)
}
if c := code(as(true, true), "/api/v1/r/theirs/end"); c == 200 {
t.Error("FAIL: a forge agent ran an operator act that is not upkeep")
}
if c := code(as(false, true), "/api/v1/r/theirs/fix"); c == 200 {
t.Error("FAIL: a token that is not a forge agent's ran upkeep")
}
if c := code(as(true, false), "/api/v1/r/theirs/fix"); c == 200 {
t.Error("FAIL: a forge agent ran upkeep on a repository its token does not reach")
}
// Readable or not; never one under a takedown, which this path
// alone would otherwise skip, and refused there like anybody else.
unreadable := func(context.Context, string) bool { return false }
held := func(_ context.Context, repo string) string {
if repo == "held" {
return "taken down"
}
return ""
}
h, err = RouterWithGates(r, "v", none, unreadable, held)
if err != nil {
t.Fatal(err)
}
if c := code(as(true, true), "/api/v1/r/private/fix"); c != 200 {
t.Errorf("FAIL: upkeep on a repository the agent cannot read was refused: %d", c)
}
if c := code(as(true, true), "/api/v1/r/held/fix"); c == 200 || c == http.StatusUnavailableForLegalReasons {
t.Errorf("FAIL: upkeep on a held private repository answered %d", c)
}
}
// A caller that went away stops the op with it, and nothing is
// journalled: no reader is waiting on that failure. One still there,
// given the same error, is a failure the journal records.
func TestACallerWhoLeftIsNotJournalled(t *testing.T) {
errlog.Default = errlog.New(50)
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[routerIn, routerOut]{
Name: "slow.get",
Doc: "Stops when its caller does.",
Kind: ops.Query,
Route: "GET /api/v1/r/{repo}/slow",
Run: func(ctx context.Context, _ routerIn) (routerOut, error) {
return routerOut{}, fmt.Errorf("archive: %w", context.Canceled)
},
}); err != nil {
t.Fatal(err)
}
h, err := Router(r, "v")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/api/v1/r/x/slow", nil).WithContext(ctx))
if n := len(errlog.Default.Recent(50)); n != 0 {
t.Errorf("FAIL: a caller who left was journalled (%d entries)", n)
}
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/api/v1/r/x/slow", nil))
if n := len(errlog.Default.Recent(50)); n != 1 {
t.Errorf("FAIL: the same error with the caller still there was not journalled (%d entries)", n)
}
}