package api_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "sourcecraft.dev/bigbes/sr-ht-spec/api" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // fakeWriter captures the request the handler builds and returns a canned // result or error, so routing, header parsing and status mapping are checked // without a service, a repository or a database. type fakeWriter struct { got service.ProposeRequest res service.ProposeResult err error } func (f *fakeWriter) Propose(_ context.Context, req service.ProposeRequest) (service.ProposeResult, error) { f.got = req return f.res, f.err } // router builds the write routes with a principal injected into every request, // bypassing token resolution — the handler reads the principal off the context, // and what put it there is not this package's concern. func router(t *testing.T, w api.Writer, p authn.Principal) http.Handler { t.Helper() srv, err := api.New(api.Options{Writer: w, Resolver: testResolver(t)}) if err != nil { t.Fatalf("api.New: %v", err) } r := chi.NewRouter() r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { next.ServeHTTP(rw, req.WithContext(authn.WithPrincipal(req.Context(), p))) }) }) srv.Register(r) return r } // testResolver is a resolver New requires but Register does not use. These // tests inject their principal directly, so it needs no agent plane. func testResolver(t *testing.T) *authn.Resolver { t.Helper() res, err := authn.NewResolver("bigbes") if err != nil { t.Fatalf("NewResolver: %v", err) } return res } func agent() authn.Principal { return authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code", Session: "s1"} } const specPath = "/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md" // TestPutOpensProposal proves the whole open path: a PUT with a body and an // If-Match opens a proposal, returns 201 with the proposal and its url, and // forwards every field to the service. func TestPutOpensProposal(t *testing.T) { w := &fakeWriter{res: service.ProposeResult{ Proposal: service.Proposal{ID: 42, Branch: "proposals/42", BaseRev: "deadbeef", State: core.StateOpen}, URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/42", }} h := router(t, w, agent()) req := httptest.NewRequest(http.MethodPut, specPath+"?title=Storage&message=add+it", strings.NewReader("the document")) req.Header.Set("If-Match", "1f0c1d1a") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body) } var body struct { Proposal int `json:"proposal"` URL string `json:"url"` State string `json:"state"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode body: %v", err) } if body.Proposal != 42 || !strings.HasSuffix(body.URL, "/p/42") || body.State != "open" { t.Fatalf("body = %+v, want proposal 42 open with its url", body) } // The request the service saw. g := w.got if g.Space != (core.SpaceRef{Owner: "bigbes", Name: "rfcs"}) { t.Errorf("space = %v", g.Space) } if g.IfMatch != "1f0c1d1a" || g.Title != "Storage" || g.Message != "add it" { t.Errorf("forwarded fields wrong: %+v", g) } if g.ProposalID != 0 { t.Errorf("ProposalID = %d, want 0 for an open", g.ProposalID) } if len(g.Writes) != 1 || g.Writes[0].Path != "specs/0007-storage.md" || string(g.Writes[0].Content) != "the document" { t.Errorf("writes = %+v", g.Writes) } // Principal is no longer comparable with == — it carries a grant set — so // its rendering stands in for it here. if g.Principal.String() != agent().String() { t.Errorf("principal = %+v, want the agent on the context", g.Principal) } } // TestPutAddsToExistingProposal proves X-Proposal routes to an add and returns // 200 rather than 201. func TestPutAddsToExistingProposal(t *testing.T) { w := &fakeWriter{res: service.ProposeResult{ Proposal: service.Proposal{ID: 7, Branch: "proposals/7", State: core.StateOpen}, URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/7", }} h := router(t, w, agent()) req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc")) req.Header.Set("If-Match", "1f0c1d1a") req.Header.Set("X-Proposal", "7") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body %s", rec.Code, rec.Body) } if w.got.ProposalID != 7 { t.Errorf("ProposalID = %d, want 7", w.got.ProposalID) } } // TestPutRequiresIfMatch refuses a write with no base. func TestPutRequiresIfMatch(t *testing.T) { w := &fakeWriter{} h := router(t, w, agent()) req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc")) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } if w.got.IfMatch != "" || w.got.Space != (core.SpaceRef{}) { t.Fatal("service was called despite a missing If-Match") } } // TestPutRejectsBadProposalHeader refuses a non-numeric X-Proposal. func TestPutRejectsBadProposalHeader(t *testing.T) { h := router(t, &fakeWriter{}, agent()) req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc")) req.Header.Set("If-Match", "1f0c1d1a") req.Header.Set("X-Proposal", "not-a-number") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } } // TestPutMapsServiceErrors proves each service sentinel reaches the right status. func TestPutMapsServiceErrors(t *testing.T) { tests := []struct { name string err error want int }{ {"forbidden", service.ErrForbidden, http.StatusForbidden}, {"invalid", service.ErrInvalid, http.StatusUnprocessableEntity}, {"stale", service.ErrStale, http.StatusConflict}, {"already merged", service.ErrAlreadyMerged, http.StatusConflict}, {"not open", service.ErrProposalNotOpen, http.StatusConflict}, {"not found", service.ErrNotFound, http.StatusNotFound}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { h := router(t, &fakeWriter{err: tc.err}, agent()) req := httptest.NewRequest(http.MethodPut, specPath, strings.NewReader("doc")) req.Header.Set("If-Match", "1f0c1d1a") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != tc.want { t.Fatalf("status = %d, want %d", rec.Code, tc.want) } }) } } // TestPutDecodesEncodedPath proves a percent-encoded document path reaches the // service decoded, so a name with a space or a Cyrillic letter is addressable. func TestPutDecodesEncodedPath(t *testing.T) { w := &fakeWriter{res: service.ProposeResult{Proposal: service.Proposal{ID: 1, State: core.StateOpen}}} h := router(t, w, agent()) req := httptest.NewRequest(http.MethodPut, "/v1/spaces/~bigbes/rfcs/docs/notes/hello%20world.md", strings.NewReader("doc")) req.Header.Set("If-Match", "1f0c1d1a") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body) } if len(w.got.Writes) != 1 || w.got.Writes[0].Path != "notes/hello world.md" { t.Fatalf("path = %q, want decoded", w.got.Writes[0].Path) } }