package graph import ( "context" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "sort" "strings" "testing" "time" "github.com/fernet/fernet-go" "github.com/go-chi/chi/v5" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "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" ) // testConf carries the crypto keys established in TestMain, 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. var testConf ini.File func TestMain(m *testing.M) { var fk fernet.Key if err := fk.Generate(); err != nil { panic("generate fernet key: " + err.Error()) } seed := make([]byte, 32) if _, err := rand.Read(seed); err != nil { panic("generate webhook seed: " + err.Error()) } testConf = ini.File{ "sr.ht": ini.Section{"network-key": fk.Encode()}, "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)}, } crypto.InitCrypto(testConf) os.Exit(m.Run()) } // ---- fixtures ------------------------------------------------------------- const ( headRev = "1111111111111111111111111111111111111111" oldRev = "2222222222222222222222222222222222222222" absentRev = "3333333333333333333333333333333333333333" agentTk = "test-agent-token" ) 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 \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 authoritative & 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 } // stubTokenStore knows exactly one live agent token. type stubTokenStore struct{} func (stubTokenStore) LookupAgentToken(_ context.Context, hash []byte) (authn.AgentToken, error) { want := authn.HashToken(agentTk) if string(hash) != string(want) { return authn.AgentToken{}, authn.ErrUnknownToken } return authn.AgentToken{ID: 1, Name: "test", Hash: want}, nil } // ---- harness -------------------------------------------------------------- type harness struct { handler http.Handler searcher *fakeSearcher proposals *fakeProposals } func newHarness(t *testing.T, withProposals bool) harness { t.Helper() resolver, err := authn.NewResolver("bigbes", stubTokenStore{}) if err != nil { t.Fatalf("NewResolver: %v", err) } searcher := &fakeSearcher{} opts := Options{Reader: newFakeReader(), Searcher: searcher, Resolver: resolver} 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.Handler(), searcher: searcher, proposals: proposals} } // response is one GraphQL response, decoded far enough to assert on. type response struct { status int body string 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 as the instance owner. func query(t *testing.T, h harness, q string) response { t.Helper() return post(t, h, q, func(req *http.Request) { login(req, "bigbes") }) } 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()} 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: authn.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 "+agentTk) }) 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, "") { 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, err := authn.NewResolver("bigbes", stubTokenStore{}) if err != nil { t.Fatalf("NewResolver: %v", err) } srv, err := New(Options{Reader: newFakeReader(), Searcher: &fakeSearcher{}, Resolver: resolver}) if err != nil { t.Fatalf("New: %v", err) } router := chi.NewRouter() router.Handle("/query", srv.Handler()) 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. func TestNewRequiresItsSeams(t *testing.T) { resolver, err := authn.NewResolver("bigbes", stubTokenStore{}) if err != nil { t.Fatalf("NewResolver: %v", err) } cases := map[string]Options{ "no reader": {Searcher: &fakeSearcher{}, Resolver: resolver}, "no searcher": {Reader: newFakeReader(), Resolver: resolver}, "no resolver": {Reader: newFakeReader(), Searcher: &fakeSearcher{}}, } for name, opts := range cases { if _, err := New(opts); err == nil { t.Errorf("%s: New succeeded", name) } } }