package graph
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
// Aliased: this file's own helper for signing a request in is called login.
ecorelogin "sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
"sourcecraft.dev/bigbes/sr-ht-spec/search"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// TestMain installs sr-ht-ecore's fixed test keyset, so a test can seal a
// unified-login cookie the way meta.sr.ht does and drive the schema through the
// same middleware the daemon installs. The keys are constants there, so this
// package and web/ can both call it without the second rotating what the first
// sealed with.
func TestMain(m *testing.M) {
ecoretest.InitCrypto()
os.Exit(m.Run())
}
// ---- fixtures -------------------------------------------------------------
const (
headRev = "1111111111111111111111111111111111111111"
oldRev = "2222222222222222222222222222222222222222"
absentRev = "3333333333333333333333333333333333333333"
)
var (
demoSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"}
otherSpace = core.SpaceRef{Owner: "bigbes", Name: "notes"}
fullProject = core.ProjectRef{Owner: "bigbes", Name: "docs"}
emptyProject = core.ProjectRef{Owner: "bigbes", Name: "fresh"}
created = time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC)
)
// headDocs is the demo space at its approved head. notes/plain.md carries no
// frontmatter at all and is therefore addressed by its path; SPEC-0009 is
// claimed twice, so it resolves to neither of its claimants.
var headDocs = map[string]string{
"specs/0007-storage.md": `---
id: SPEC-0007
title: Proposal storage model
status: draft
type: spec
supersedes: SPEC-0003
owners: [~bigbes]
tags: [storage, review]
summary: How proposals are stored.
---
# Proposal storage model
Git is authoritative.
`,
"specs/0009-a.md": `---
id: SPEC-0009
title: One claimant
status: draft
---
# One claimant
`,
"specs/0009-b.md": `---
id: SPEC-0009
title: The other claimant
status: draft
---
# The other claimant
`,
"notes/plain.md": `# Just a note
No frontmatter here at all.
`,
}
// oldDocs is the same space at a pinned, older revision. The title differs, so
// a test can prove that `rev:` actually reached a different tree.
var oldDocs = map[string]string{
"specs/0007-storage.md": `---
id: SPEC-0007
title: Storage, first draft
status: draft
---
# Storage, first draft
`,
}
// fakeReader is an in-memory Reader: a space is a revision-keyed set of
// documents, and a project is a list of member spaces.
type fakeReader struct {
revs map[string]map[string]string // rev -> path -> content
head string
projects map[core.ProjectRef][]core.SpaceRef
}
func newFakeReader() *fakeReader {
return &fakeReader{
revs: map[string]map[string]string{headRev: headDocs, oldRev: oldDocs},
head: headRev,
projects: map[core.ProjectRef][]core.SpaceRef{
fullProject: {demoSpace},
emptyProject: nil,
},
}
}
func spaceOf(ref core.SpaceRef) *service.Space {
return &service.Space{Ref: ref, ID: 1, Created: created}
}
func (f *fakeReader) ListSpaces(context.Context) ([]*service.Space, error) {
return []*service.Space{spaceOf(demoSpace), spaceOf(otherSpace)}, nil
}
func (f *fakeReader) OpenSpace(_ context.Context, ref core.SpaceRef) (*service.Space, error) {
if ref != demoSpace {
return nil, fmt.Errorf("%w: space %s", service.ErrNotFound, ref)
}
return spaceOf(ref), nil
}
// at resolves a revision the way service does: ApprovedRev is the approved
// head, and anything else must name a revision that exists.
func (f *fakeReader) at(rev string) (string, map[string]string, error) {
if rev == service.ApprovedRev {
rev = f.head
}
docs, ok := f.revs[rev]
if !ok {
return "", nil, fmt.Errorf("%w: revision %q", service.ErrNotFound, rev)
}
return rev, docs, nil
}
func (f *fakeReader) ResolveRev(_ context.Context, _ *service.Space, rev string) (string, error) {
resolved, _, err := f.at(rev)
return resolved, err
}
func (f *fakeReader) Archive(_ context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, error) {
resolved, docs, err := f.at(rev)
if err != nil {
return nil, nil, err
}
paths := make([]string, 0, len(docs))
for p := range docs {
paths = append(paths, p)
}
sort.Strings(paths)
// Built through service.ArchiveFrom, which is the seam it exists for: the
// addressing rule under test is the production one rather than a fixture's
// idea of it.
sd := make([]service.Document, 0, len(paths))
for _, p := range paths {
data := []byte(docs[p])
sd = append(sd, service.Document{Path: p, Blob: blobSha(data), Rev: resolved, Data: data})
}
return service.ArchiveFrom(sp.Ref, resolved, sd)
}
func (f *fakeReader) GetProject(_ context.Context, ref core.ProjectRef) (*service.Project, error) {
if _, ok := f.projects[ref]; !ok {
return nil, fmt.Errorf("%w: project %s", service.ErrNotFound, ref)
}
return &service.Project{Ref: ref, ID: 1, Created: created}, nil
}
func (f *fakeReader) ListProjects(context.Context) ([]*service.Project, error) {
return []*service.Project{
{Ref: fullProject, ID: 1, Created: created},
{Ref: emptyProject, ID: 2, Created: created},
}, nil
}
func (f *fakeReader) ProjectSpaces(_ context.Context, ref core.ProjectRef) ([]*service.Space, error) {
// The meta-project lists every space, exactly as service.ProjectSpaces does:
// a listing is enumerated by definition, even though the filter it resolves
// to is not.
if ref.IsMeta() {
return f.ListSpaces(context.Background())
}
members, ok := f.projects[ref]
if !ok {
return nil, fmt.Errorf("%w: project %s", service.ErrNotFound, ref)
}
out := make([]*service.Space, 0, len(members))
for _, m := range members {
out = append(out, spaceOf(m))
}
return out, nil
}
// blobSha is git's object name for a blob: sha1 over "blob <len>\0" and the
// content. Spelled out rather than taken from go-git so that this package —
// tests included — never imports the git layer, which is the whole point of
// reading through service/.
func blobSha(data []byte) string {
h := sha1.New()
fmt.Fprintf(h, "blob %d", len(data))
h.Write([]byte{0})
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
// fakeSearcher records the query it was handed and returns one fixed hit.
type fakeSearcher struct {
last search.Query
}
func (s *fakeSearcher) Search(_ context.Context, q search.Query) (search.Results, error) {
s.last = q
// The two answers the index knows without looking, spelled the same way
// search.Index spells them: a scope nobody set is a caller bug, and a
// filter that selects no space has no hits by construction.
if q.Spaces.IsZero() {
return search.Results{}, fmt.Errorf("search: query names no space scope")
}
if q.Spaces.MatchesNothing() {
return search.Results{}, nil
}
return search.Results{
Total: 1,
Took: 3 * time.Millisecond,
Hits: []search.Hit{{
Space: demoSpace,
ID: "SPEC-0007",
Rev: headRev,
Path: "specs/0007-storage.md",
Title: "Proposal storage model",
Section: "specs",
Lang: search.LangEN,
Score: 1.5,
Snippet: `Git is <mark>authoritative</mark> & boring`,
}},
}, nil
}
// fakeProposals is the port service/ does not implement yet.
type fakeProposals struct {
rows []Proposal
}
func (f *fakeProposals) ListProposals(_ context.Context, space core.SpaceRef, state core.ProposalState) ([]Proposal, error) {
var out []Proposal
for _, p := range f.rows {
if p.Space == space && p.State == state {
out = append(out, p)
}
}
return out, nil
}
// stubUsers resolves the owner an instance token names to a local row.
type stubUsers struct{}
func (stubUsers) LookupUser(_ context.Context, username string) (authn.InstanceUser, error) {
return authn.InstanceUser{ID: 1, Username: username}, nil
}
// testResolver is the resolver the daemon builds, with the one agent credential
// plane wired. Its origin is never reached: these tests present stateless
// tokens, which carry no row id and so skip the revocation round trip entirely.
func testResolver(t *testing.T) *authn.Resolver {
t.Helper()
v, err := bearer.New(bearer.Options{
Origin: "https://tokens.srht.invalid",
ClientID: "spec.sr.ht",
NodeID: "graph-test",
})
if err != nil {
t.Fatalf("bearer.New: %v", err)
}
resolver, err := authn.NewResolver("bigbes", authn.WithInstancePlane(v, stubUsers{}))
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
return resolver
}
// agentToken mints a signed tokens.sr.ht working token carrying grantString. It
// cannot be a package-level constant the way the old opaque secret was: the
// signing key is established by TestMain.
func agentToken(grantString string) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(time.Now().Add(time.Hour)),
Grants: grantString,
ClientID: bearer.TokensClientID,
Username: "bigbes",
}
return bt.Encode()
}
// ---- harness --------------------------------------------------------------
type harness struct {
handler http.Handler
searcher *fakeSearcher
proposals *fakeProposals
}
func newHarness(t *testing.T, withProposals bool) harness {
t.Helper()
resolver := testResolver(t)
searcher := &fakeSearcher{}
opts := Options{
Reader: newFakeReader(),
Searcher: searcher,
Resolver: resolver,
Meta: testMetaAuth(t),
}
var proposals *fakeProposals
if withProposals {
proposals = &fakeProposals{}
opts.Proposals = proposals
}
srv, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
return harness{handler: srv, searcher: searcher, proposals: proposals}
}
// response is one GraphQL response, decoded far enough to assert on.
//
// The headers are kept because a refusal's status is only half of it: a 401 owes
// the caller the bearer challenge, and a 403 must not carry one — a challenge
// tells a client to go and re-mint a credential that is in fact perfectly good.
type response struct {
status int
body string
header http.Header
Data json.RawMessage `json:"data"`
Errors []graphqlError `json:"errors"`
}
type graphqlError struct {
Message string `json:"message"`
}
// errText joins every error message, for assertions that care what was said.
func (r response) errText() string {
var msgs []string
for _, e := range r.Errors {
msgs = append(msgs, e.Message)
}
return strings.Join(msgs, "; ")
}
// query POSTs a GraphQL query with the credential this endpoint accepts: a
// tokens.sr.ht working token belonging to the instance owner and carrying
// spec:read. It used to seal the owner's unified-login cookie, which /query no
// longer looks at — see TestCookieIsNotACredentialHere.
func query(t *testing.T, h harness, q string) response {
t.Helper()
return post(t, h, q, readToken)
}
// readToken presents a working token carrying authn.ActionRead.
func readToken(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionRead))
}
func post(t *testing.T, h harness, q string, auth func(*http.Request)) response {
t.Helper()
body, err := json.Marshal(map[string]any{"query": q})
if err != nil {
t.Fatalf("marshal query: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
if auth != nil {
auth(req)
}
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
out := response{status: rec.Code, body: rec.Body.String(), header: rec.Header()}
if strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
}
}
return out
}
// ok fails unless the query succeeded outright, and decodes data into v.
func ok(t *testing.T, r response, v any) {
t.Helper()
if r.status != http.StatusOK {
t.Fatalf("status %d, body %s", r.status, r.body)
}
if len(r.Errors) > 0 {
t.Fatalf("unexpected errors: %s", r.errText())
}
if v != nil {
if err := json.Unmarshal(r.Data, v); err != nil {
t.Fatalf("decode data %s: %v", r.Data, err)
}
}
}
// login seals a unified-login cookie for user onto a request — the same shape
// meta.sr.ht writes, sealed with the shared network key.
func login(req *http.Request, user string) {
payload, _ := json.Marshal(map[string]string{"name": user})
req.AddCookie(&http.Cookie{Name: ecorelogin.CookieName, Value: string(crypto.Encrypt(payload))})
}
// ---- the read contract ----------------------------------------------------
// A read with no rev is a read of the approved head, and reports the sha it
// resolved to so the caller can pin the next one.
func TestDocumentDefaultsToTheApprovedHead(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{
document(space: "~bigbes/rfcs", id: "SPEC-0007") {
id docId path rev blob pinned title status section summary type supersedes tags owners markdown
}
}`)
var got struct {
Document struct {
ID, Path, Rev, Blob string
DocID *string
Pinned bool
Title, Status, Section string
Summary, Type, Supersedes string
Tags, Owners []string
Markdown string
}
}
ok(t, r, &got)
if got.Document.Rev != headRev {
t.Errorf("rev = %q, want the approved head %q", got.Document.Rev, headRev)
}
if got.Document.Pinned {
t.Error("pinned = true for a read that named no revision")
}
if got.Document.Title != "Proposal storage model" {
t.Errorf("title = %q, want the head's title", got.Document.Title)
}
if got.Document.ID != "SPEC-0007" || got.Document.DocID == nil || *got.Document.DocID != "SPEC-0007" {
t.Errorf("id/docId = %q/%v, want both SPEC-0007", got.Document.ID, got.Document.DocID)
}
if got.Document.Path != "specs/0007-storage.md" {
t.Errorf("path = %q", got.Document.Path)
}
if got.Document.Blob != blobSha([]byte(headDocs["specs/0007-storage.md"])) {
t.Errorf("blob = %q, want the content hash", got.Document.Blob)
}
if got.Document.Section != "specs" || got.Document.Status != "draft" {
t.Errorf("section/status = %q/%q", got.Document.Section, got.Document.Status)
}
if got.Document.Type != "spec" || got.Document.Supersedes != "SPEC-0003" {
t.Errorf("type/supersedes = %q/%q", got.Document.Type, got.Document.Supersedes)
}
if strings.Join(got.Document.Tags, ",") != "storage,review" {
t.Errorf("tags = %v", got.Document.Tags)
}
if strings.Join(got.Document.Owners, ",") != "~bigbes" {
t.Errorf("owners = %v", got.Document.Owners)
}
if !strings.HasPrefix(got.Document.Markdown, "---\nid: SPEC-0007") {
t.Errorf("markdown does not carry the frontmatter: %q", got.Document.Markdown)
}
}
// A full object name pins the read to that revision, and says so.
func TestDocumentPinnedToARevision(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{
document(space: "~bigbes/rfcs", id: "SPEC-0007", rev: "`+oldRev+`") { rev pinned title }
}`)
var got struct {
Document struct {
Rev string
Pinned bool
Title string
}
}
ok(t, r, &got)
if got.Document.Rev != oldRev {
t.Errorf("rev = %q, want the pinned %q", got.Document.Rev, oldRev)
}
if !got.Document.Pinned {
t.Error("pinned = false for a read that named a revision")
}
if got.Document.Title != "Storage, first draft" {
t.Errorf("title = %q — the pin did not reach the older tree", got.Document.Title)
}
}
// A ref name is not a revision this plane will serve. Without the guard,
// rev: "proposals/42" would hand back unreviewed proposal content as though it
// were approved — the one failure this service exists to prevent.
func TestRefNameRevIsRefused(t *testing.T) {
h := newHarness(t, false)
// A branch name, the approved branch itself, a full ref, an abbreviation
// that is unique today and may not be tomorrow, and an object name in the
// wrong case.
for _, rev := range []string{"proposals/42", "main", "refs/heads/main", "1111111", strings.Repeat("A", 40)} {
r := query(t, h, `{ document(space: "~bigbes/rfcs", id: "SPEC-0007", rev: "`+rev+`") { rev } }`)
if len(r.Errors) == 0 {
t.Errorf("rev %q was accepted: %s", rev, r.body)
continue
}
if !strings.Contains(r.errText(), "object name") {
t.Errorf("rev %q refused, but not for being a bad object name: %s", rev, r.errText())
}
}
}
// A revision that is well-formed but absent is absence, not a bad argument: the
// field is null and no error is reported.
func TestAbsentRevisionIsNull(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ document(space: "~bigbes/rfcs", id: "SPEC-0007", rev: "`+absentRev+`") { rev } }`)
var got struct {
Document *struct{ Rev string }
}
ok(t, r, &got)
if got.Document != nil {
t.Errorf("document = %+v, want null", got.Document)
}
}
// The addressing rule: a document with no usable id is addressed by its path,
// and an id two documents claim resolves to neither of them.
func TestAddressing(t *testing.T) {
h := newHarness(t, false)
t.Run("path of a document with no frontmatter", func(t *testing.T) {
r := query(t, h, `{ document(space: "~bigbes/rfcs", id: "notes/plain") { id docId title } }`)
var got struct {
Document struct {
ID string
DocID *string
Title string
}
}
ok(t, r, &got)
if got.Document.ID != "notes/plain" {
t.Errorf("id = %q, want the path minus the extension", got.Document.ID)
}
if got.Document.DocID != nil {
t.Errorf("docId = %v, want null for a document with no frontmatter id", *got.Document.DocID)
}
})
t.Run("by path", func(t *testing.T) {
r := query(t, h, `{ document(space: "~bigbes/rfcs", path: "specs/0007-storage.md") { id } }`)
var got struct{ Document struct{ ID string } }
ok(t, r, &got)
if got.Document.ID != "SPEC-0007" {
t.Errorf("id = %q", got.Document.ID)
}
})
t.Run("a duplicated id resolves to neither document", func(t *testing.T) {
r := query(t, h, `{ document(space: "~bigbes/rfcs", id: "SPEC-0009") { id path } }`)
if len(r.Errors) == 0 {
t.Fatalf("a duplicated id resolved: %s", r.body)
}
if !strings.Contains(r.errText(), "claimed by 2 documents") {
t.Errorf("error does not name the collision: %s", r.errText())
}
})
t.Run("both id and path", func(t *testing.T) {
r := query(t, h, `{ document(space: "~bigbes/rfcs", id: "SPEC-0007", path: "notes/plain.md") { id } }`)
if len(r.Errors) == 0 {
t.Fatalf("id and path together were accepted: %s", r.body)
}
})
t.Run("neither id nor path", func(t *testing.T) {
r := query(t, h, `{ document(space: "~bigbes/rfcs") { id } }`)
if len(r.Errors) == 0 {
t.Fatalf("an unaddressed document was accepted: %s", r.body)
}
})
}
// The listing is the same revision resolution, and carries every document.
func TestDocumentsListing(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ documents(space: "~bigbes/rfcs") { id rev pinned } }`)
var got struct {
Documents []struct {
ID string
Rev string
Pinned bool
}
}
ok(t, r, &got)
if len(got.Documents) != len(headDocs) {
t.Fatalf("got %d documents, want %d", len(got.Documents), len(headDocs))
}
for _, d := range got.Documents {
if d.Rev != headRev || d.Pinned {
t.Errorf("%s: rev/pinned = %q/%v", d.ID, d.Rev, d.Pinned)
}
}
}
// ---- authorization --------------------------------------------------------
// The read plane is fail-closed: the owner and its agents read, and nobody
// else does. A refusal is a 401 with no content, never a page of login markup.
func TestAnonymousIsRefused(t *testing.T) {
h := newHarness(t, false)
cases := map[string]func(*http.Request){
"no credential": nil,
"a stranger's cookie": func(req *http.Request) { login(req, "somebody-else") },
"an unknown token": func(req *http.Request) {
req.Header.Set("Authorization", "Bearer not-a-real-token")
},
}
for name, auth := range cases {
t.Run(name, func(t *testing.T) {
r := post(t, h, `{ documents(space: "~bigbes/rfcs") { markdown } }`, auth)
if r.status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body %s", r.status, r.body)
}
if strings.Contains(r.body, "SPEC-0007") || strings.Contains(r.body, "authoritative") {
t.Fatalf("the refusal leaked content: %s", r.body)
}
})
}
}
// Introspection is content too, in the sense that matters here: it is served
// only to a caller with read authority.
func TestIntrospectionIsGated(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, `{ __schema { queryType { name } } }`, nil)
if r.status != http.StatusUnauthorized {
t.Fatalf("anonymous introspection: status = %d, want 401", r.status)
}
}
// An agent token reads. This is the other half of the ACL and the half the
// whole service is for.
func TestAgentTokenReads(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, `{ document(space: "~bigbes/rfcs", id: "SPEC-0007") { title } }`, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken("spec:read"))
})
var got struct{ Document struct{ Title string } }
ok(t, r, &got)
if got.Document.Title != "Proposal storage model" {
t.Errorf("title = %q", got.Document.Title)
}
}
// ---- search ---------------------------------------------------------------
func TestSearchSpaceFiltering(t *testing.T) {
t.Run("a space list is the filter", func(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{
search(query: "storage", spaces: ["~bigbes/rfcs"]) {
total took hits { space id rev path title section lang score snippet }
}
}`)
var got struct {
Search struct {
Total int
Took string
Hits []struct {
Space, ID, Rev, Path, Title, Section, Lang, Snippet string
Score float64
}
}
}
ok(t, r, &got)
got_ := h.searcher.last.Spaces
if got_.Everything() || len(got_.Refs()) != 1 || got_.Refs()[0] != demoSpace {
t.Fatalf("query.Spaces = %s, want just %s", got_, demoSpace)
}
if h.searcher.last.Text != "storage" {
t.Errorf("query.Text = %q", h.searcher.last.Text)
}
if got.Search.Total != 1 || len(got.Search.Hits) != 1 {
t.Fatalf("total/hits = %d/%d", got.Search.Total, len(got.Search.Hits))
}
hit := got.Search.Hits[0]
if hit.Space != "~bigbes/rfcs" || hit.Rev != headRev || hit.Path != "specs/0007-storage.md" {
t.Errorf("hit = %+v — a hit must be a pinned address", hit)
}
if !strings.Contains(hit.Snippet, "<mark>") {
t.Errorf("snippet lost its highlighting: %q", hit.Snippet)
}
})
t.Run("an omitted space list is every space", func(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ search(query: "storage") { total } }`)
ok(t, r, nil)
if !h.searcher.last.Spaces.Everything() {
t.Errorf("query.Spaces = %s, want the filter that excludes nothing", h.searcher.last.Spaces)
}
})
// The polarity trap, from the client's side: an empty project resolves to
// no spaces, and passing that membership to a search must return nothing
// rather than the whole corpus. null and [] are different arguments here,
// which is what makes the two expressible at all.
t.Run("an empty space list selects nothing", func(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ search(query: "storage", spaces: []) { total hits { id } } }`)
var got struct {
Search struct {
Total int
Hits []struct{ ID string }
}
}
ok(t, r, &got)
if got.Search.Total != 0 || len(got.Search.Hits) != 0 {
t.Fatalf("an empty space list matched %d/%d — it must select nothing", got.Search.Total, len(got.Search.Hits))
}
if h.searcher.last.Spaces.Everything() || !h.searcher.last.Spaces.MatchesNothing() {
t.Errorf("query.Spaces = %s, want a filter selecting nothing", h.searcher.last.Spaces)
}
})
// End to end: what a client actually does with an empty project.
t.Run("an empty project searched through its membership returns no hits", func(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ project(owner: "bigbes", name: "fresh") { spaces { ref } } }`)
var project struct {
Project struct {
Spaces []struct{ Ref string }
}
}
ok(t, r, &project)
refs := make([]string, 0, len(project.Project.Spaces))
for _, s := range project.Project.Spaces {
refs = append(refs, `"`+s.Ref+`"`)
}
r = query(t, h, `{ search(query: "storage", spaces: [`+strings.Join(refs, ",")+`]) { total } }`)
var got struct{ Search struct{ Total int } }
ok(t, r, &got)
if got.Search.Total != 0 {
t.Fatalf("an empty project's search matched %d documents", got.Search.Total)
}
})
t.Run("limit and offset are passed through", func(t *testing.T) {
h := newHarness(t, false)
ok(t, query(t, h, `{ search(query: "storage", limit: 5, offset: 10) { total } }`), nil)
if h.searcher.last.Limit != 5 || h.searcher.last.Offset != 10 {
t.Errorf("limit/offset = %d/%d", h.searcher.last.Limit, h.searcher.last.Offset)
}
})
t.Run("a negative limit is refused", func(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ search(query: "storage", limit: -1) { total } }`)
if len(r.Errors) == 0 {
t.Fatalf("a negative limit was accepted: %s", r.body)
}
})
}
// ---- spaces and projects --------------------------------------------------
func TestSpaces(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ spaces { owner name ref created } }`)
var got struct {
Spaces []struct {
Owner, Name, Ref string
Created time.Time
}
}
ok(t, r, &got)
if len(got.Spaces) != 2 {
t.Fatalf("got %d spaces", len(got.Spaces))
}
if got.Spaces[0].Ref != "~bigbes/rfcs" || got.Spaces[0].Owner != "bigbes" || got.Spaces[0].Name != "rfcs" {
t.Errorf("space = %+v", got.Spaces[0])
}
if !got.Spaces[0].Created.Equal(created) {
t.Errorf("created = %v", got.Spaces[0].Created)
}
}
func TestSpaceApprovedRev(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ space(owner: "bigbes", name: "rfcs") { ref approvedRev } }`)
var got struct {
Space struct{ Ref, ApprovedRev string }
}
ok(t, r, &got)
if got.Space.ApprovedRev != headRev {
t.Errorf("approvedRev = %q, want %q", got.Space.ApprovedRev, headRev)
}
}
func TestAbsentSpaceIsNull(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ space(owner: "bigbes", name: "nope") { ref } }`)
var got struct{ Space *struct{ Ref string } }
ok(t, r, &got)
if got.Space != nil {
t.Errorf("space = %+v, want null", got.Space)
}
}
func TestOwnerDecorationIsNotPartOfTheName(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ space(owner: "~bigbes", name: "rfcs") { ref } }`)
if len(r.Errors) == 0 {
t.Fatalf("a '~'-decorated owner was accepted: %s", r.body)
}
}
func TestProjects(t *testing.T) {
h := newHarness(t, false)
t.Run("a project lists its member spaces", func(t *testing.T) {
r := query(t, h, `{ project(owner: "bigbes", name: "docs") { ref meta spaces { ref } } }`)
var got struct {
Project struct {
Ref string
Meta bool
Spaces []struct{ Ref string }
}
}
ok(t, r, &got)
if got.Project.Ref != "~bigbes/+docs" || got.Project.Meta {
t.Errorf("project = %+v", got.Project)
}
if len(got.Project.Spaces) != 1 || got.Project.Spaces[0].Ref != "~bigbes/rfcs" {
t.Errorf("spaces = %+v", got.Project.Spaces)
}
})
// A project is a saved filter, and a filter with no terms selects nothing.
// Collapsing that into "everything" would turn a freshly created project
// into the whole corpus — invisibly, and the opposite of what its author
// asked for.
t.Run("an empty project selects nothing", func(t *testing.T) {
r := query(t, h, `{ project(owner: "bigbes", name: "fresh") { ref spaces { ref } } }`)
var got struct {
Project struct {
Ref string
Spaces []struct{ Ref string }
}
}
ok(t, r, &got)
if len(got.Project.Spaces) != 0 {
t.Fatalf("an empty project resolved to %d spaces: %+v", len(got.Project.Spaces), got.Project.Spaces)
}
})
// The meta-project is an address that resolves to a filter, not a row: it
// has no membership to be missing and cannot be looked up.
t.Run("the meta-project resolves without a row", func(t *testing.T) {
r := query(t, h, `{ project(owner: "bigbes", name: "everything") { ref meta spaces { ref } } }`)
var got struct {
Project struct {
Ref string
Meta bool
Spaces []struct{ Ref string }
}
}
ok(t, r, &got)
if got.Project.Ref != "~bigbes/+everything" || !got.Project.Meta {
t.Errorf("meta-project = %+v", got.Project)
}
if len(got.Project.Spaces) != 2 {
t.Errorf("the meta-project lists %d spaces, want every space", len(got.Project.Spaces))
}
})
t.Run("an absent project is null", func(t *testing.T) {
r := query(t, h, `{ project(owner: "bigbes", name: "nope") { ref } }`)
var got struct{ Project *struct{ Ref string } }
ok(t, r, &got)
if got.Project != nil {
t.Errorf("project = %+v, want null", got.Project)
}
})
// The listing is rows only. The meta-project is not one, which is what
// makes it impossible to rename, delete or forget to keep in step.
t.Run("the listing carries only stored projects", func(t *testing.T) {
r := query(t, h, `{ projects { ref meta } }`)
var got struct {
Projects []struct {
Ref string
Meta bool
}
}
ok(t, r, &got)
if len(got.Projects) != 2 {
t.Fatalf("got %d projects", len(got.Projects))
}
for _, p := range got.Projects {
if p.Meta {
t.Errorf("%s is listed as the meta-project", p.Ref)
}
}
})
}
// ---- proposals ------------------------------------------------------------
func TestProposals(t *testing.T) {
h := newHarness(t, true)
merged := created.Add(time.Hour)
h.proposals.rows = []Proposal{
{
ID: 42, Space: demoSpace, Title: "Rewrite the storage model",
Rationale: "because", BaseRev: headRev, Branch: "proposals/42",
State: core.StateOpen, Agent: "claude-code/spec-writer",
AgentSession: "session-1", Created: created,
},
{
ID: 43, Space: demoSpace, Title: "Already landed",
BaseRev: headRev, Branch: "proposals/43", State: core.StateMerged,
Approval: core.ApprovalPolicy, MergedRev: oldRev,
Agent: "claude-code/spec-writer", AgentSession: "session-2",
Created: created, Resolved: &merged,
},
}
t.Run("open", func(t *testing.T) {
r := query(t, h, `{
proposals(space: "~bigbes/rfcs", state: OPEN) {
id space title rationale baseRev branch state approval mergedRev agent agentSession created resolved
}
}`)
var got struct {
Proposals []struct {
ID int
Space, Title, Rationale, BaseRev string
Branch, State string
Approval, MergedRev *string
Agent, AgentSession string
Created time.Time
Resolved *time.Time
}
}
ok(t, r, &got)
if len(got.Proposals) != 1 {
t.Fatalf("got %d open proposals", len(got.Proposals))
}
p := got.Proposals[0]
if p.ID != 42 || p.State != "OPEN" || p.Branch != "proposals/42" {
t.Errorf("proposal = %+v", p)
}
// An unmerged proposal has no approval and no merge commit, and must
// not be reported as though it had either.
if p.Approval != nil || p.MergedRev != nil || p.Resolved != nil {
t.Errorf("an open proposal reported approval=%v mergedRev=%v resolved=%v", p.Approval, p.MergedRev, p.Resolved)
}
})
// Auto-merged is not human-approved, and a reader must be able to tell.
t.Run("merged carries how it was approved", func(t *testing.T) {
r := query(t, h, `{ proposals(space: "~bigbes/rfcs", state: MERGED) { id state approval mergedRev resolved } }`)
var got struct {
Proposals []struct {
ID int
State string
Approval, MergedRev *string
Resolved *time.Time
}
}
ok(t, r, &got)
if len(got.Proposals) != 1 {
t.Fatalf("got %d merged proposals", len(got.Proposals))
}
p := got.Proposals[0]
if p.Approval == nil || *p.Approval != "POLICY" {
t.Errorf("approval = %v, want POLICY", p.Approval)
}
if p.MergedRev == nil || *p.MergedRev != oldRev || p.Resolved == nil {
t.Errorf("mergedRev/resolved = %v/%v", p.MergedRev, p.Resolved)
}
})
// Nothing implements the port yet. The field says so rather than answering
// "no proposals", which would tell a reviewer their queue is clear when it
// is merely unread.
t.Run("unwired", func(t *testing.T) {
bare := newHarness(t, false)
r := query(t, bare, `{ proposals(space: "~bigbes/rfcs", state: OPEN) { id } }`)
if len(r.Errors) == 0 {
t.Fatalf("an unwired proposal listing answered: %s", r.body)
}
if !strings.Contains(r.errText(), "no proposal listing") {
t.Errorf("error does not say what is missing: %s", r.errText())
}
})
}
// ---- the schema itself ----------------------------------------------------
// The only mutations are for webhooks (Phase 5). The proposal write plane
// stays off this surface deliberately: its `If-Match` concurrency is an HTTP
// idiom, and a federated type is a consumed contract, so the proposal types are
// not mutated here until they stop moving. A *proposal* mutation appearing here
// is a design change and should fail this test first. There are still no
// subscriptions.
func TestSchemaMutationsAreWebhooksOnly(t *testing.T) {
h := newHarness(t, false)
r := query(t, h, `{ __schema {
mutationType { name fields { name } }
subscriptionType { name }
} }`)
var got struct {
Schema struct {
MutationType *struct {
Name string
Fields []struct{ Name string }
}
SubscriptionType *struct{ Name string }
} `json:"__schema"`
}
ok(t, r, &got)
if got.Schema.MutationType == nil {
t.Fatalf("the schema declares no mutation type; the webhook mutations should be present")
}
want := map[string]bool{"createUserWebhook": true, "deleteUserWebhook": true}
for _, f := range got.Schema.MutationType.Fields {
if !want[f.Name] {
t.Errorf("unexpected mutation %q; only webhook mutations belong on this surface "+
"(proposal writes stay on REST/MCP)", f.Name)
}
delete(want, f.Name)
}
for name := range want {
t.Errorf("missing expected webhook mutation %q", name)
}
if got.Schema.SubscriptionType != nil {
t.Errorf("the schema declares a subscription type %q", got.Schema.SubscriptionType.Name)
}
}
// The mounting call the package documents, exercised: a chi router with no
// middleware of its own, the endpoint at /query, and a query that goes through.
// The daemon's one line is the line under test here.
func TestMountedOnAChiRouter(t *testing.T) {
resolver := testResolver(t)
srv, err := New(Options{
Reader: newFakeReader(), Searcher: &fakeSearcher{},
Resolver: resolver, Meta: testMetaAuth(t),
})
if err != nil {
t.Fatalf("New: %v", err)
}
router := chi.NewRouter()
router.Handle("/query", srv)
h := harness{handler: router}
var got struct{ Space struct{ Ref string } }
ok(t, query(t, h, `{ space(owner: "bigbes", name: "rfcs") { ref } }`), &got)
if got.Space.Ref != "~bigbes/rfcs" {
t.Errorf("ref = %q", got.Space.Ref)
}
// And the gate is still in front of it once mounted.
if r := post(t, h, `{ spaces { ref } }`, nil); r.status != http.StatusUnauthorized {
t.Errorf("anonymous request through the router: status = %d, want 401", r.status)
}
}
// New refuses a half-wired server at startup rather than failing inside the
// first query, and says which seam is missing.
//
// The meta plane is the case that would not announce itself, and it is why the
// error text is asserted rather than only the failure. An endpoint built without
// it starts, serves every working token exactly as before and passes every other
// test in this file — while answering 401 to every federated query, a failure
// visible only from api.sr.ht and only once somebody has added the `api-origin=`
// line. "New succeeded" would not have been enough to tell that seam from any
// other.
func TestNewRequiresItsSeams(t *testing.T) {
full := func() Options {
return Options{
Reader: newFakeReader(),
Searcher: &fakeSearcher{},
Resolver: testResolver(t),
Meta: testMetaAuth(t),
}
}
cases := map[string]func(*Options){
"Reader": func(o *Options) { o.Reader = nil },
"Searcher": func(o *Options) { o.Searcher = nil },
"Resolver": func(o *Options) { o.Resolver = nil },
"MetaAuthenticator": func(o *Options) { o.Meta = nil },
}
for name, drop := range cases {
t.Run("without a "+name, func(t *testing.T) {
opts := full()
drop(&opts)
_, err := New(opts)
require.Error(t, err)
assert.Contains(t, err.Error(), name)
})
}
// The control: everything above fails for the seam it dropped and not because
// the fixture was never buildable. Proposals stays nil throughout, being the
// one genuinely optional seam — while it is, the `proposals` field fails
// loudly rather than answering "none".
_, err := New(full())
require.NoError(t, err)
}