package graph import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" "sourcecraft.dev/bigbes/sr-ht-dolt/db" ) // TestMain seeds the process-global crypto state: the pagination cursor is // core-go's, which encrypts itself with the instance keys. func TestMain(m *testing.M) { ecoretest.InitCrypto() m.Run() } // --- fakes ------------------------------------------------------------------- type fakeRepos struct { repos map[string]*core.Repo // "owner/name" order []*core.Repo // listing order, newest first acls map[int]map[int]core.AccessMode names map[int]string // user id -> username, for ListACL listErr error getErr error accessErr error } func newFakeRepos() *fakeRepos { return &fakeRepos{ repos: map[string]*core.Repo{}, acls: map[int]map[int]core.AccessMode{}, names: map[int]string{}, } } func (f *fakeRepos) add(r *core.Repo) *core.Repo { r.ID = len(f.order) + 1 r.Path = "/var/lib/dolt/~" + r.OwnerName + "/" + r.Name if r.Created.IsZero() { r.Created = time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) r.Updated = r.Created } f.repos[r.OwnerName+"/"+r.Name] = r // Newest first, as the store's ORDER BY created DESC, id DESC yields. f.order = append([]*core.Repo{r}, f.order...) f.names[r.OwnerID] = r.OwnerName return r } func (f *fakeRepos) grant(repoID, userID int, mode core.AccessMode, username string) { if f.acls[repoID] == nil { f.acls[repoID] = map[int]core.AccessMode{} } f.acls[repoID][userID] = mode f.names[userID] = username } func (f *fakeRepos) GetRepoByOwnerAndName(_ context.Context, owner, name string) (*core.Repo, error) { if f.getErr != nil { return nil, f.getErr } r, ok := f.repos[owner+"/"+name] if !ok { return nil, db.ErrNotFound } return r, nil } // visible mirrors db.Store's listing rule: PUBLIC to everyone, plus whatever the // viewer owns or holds an ACL entry on. func (f *fakeRepos) visible(r *core.Repo, viewer *core.Caller) bool { if r.Visibility == core.VisibilityPublic { return true } if viewer == nil { return false } if viewer.UserID == r.OwnerID { return true } _, ok := f.acls[r.ID][viewer.UserID] return ok } func (f *fakeRepos) ListReposForViewer(_ context.Context, viewer *core.Caller) ([]*core.Repo, error) { if f.listErr != nil { return nil, f.listErr } var out []*core.Repo for _, r := range f.order { if f.visible(r, viewer) { out = append(out, r) } } return out, nil } func (f *fakeRepos) ListReposByOwner(ctx context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) { all, err := f.ListReposForViewer(ctx, viewer) if err != nil { return nil, err } var out []*core.Repo for _, r := range all { if r.OwnerName == owner { out = append(out, r) } } return out, nil } func (f *fakeRepos) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) { if f.accessErr != nil { return nil, f.accessErr } mode, ok := f.acls[repoID][userID] if !ok { return nil, nil } return &mode, nil } func (f *fakeRepos) ListACL(_ context.Context, repoID int) ([]*db.ACLEntry, error) { var out []*db.ACLEntry for uid, mode := range f.acls[repoID] { out = append(out, &db.ACLEntry{RepoID: repoID, UserID: uid, Username: f.names[uid], Mode: mode}) } return out, nil } type fakeSession struct { branches []browse.Branch commits []browse.CommitInfo next string tables []browse.TableInfo err error logRef string logFrom string logLimit int closed bool } func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, s.err } func (s *fakeSession) Log(_ context.Context, ref, from string, limit int) ([]browse.CommitInfo, string, error) { s.logRef, s.logFrom, s.logLimit = ref, from, limit return s.commits, s.next, s.err } func (s *fakeSession) Tables(context.Context, string) ([]browse.TableInfo, error) { return s.tables, s.err } func (s *fakeSession) Close() error { s.closed = true; return nil } type fakeOpener struct { sess *fakeSession openErr error mu sync.Mutex opens []string } func (o *fakeOpener) Open(_ context.Context, diskPath string) (BrowseSession, error) { o.mu.Lock() o.opens = append(o.opens, diskPath) o.mu.Unlock() if o.openErr != nil { return nil, o.openErr } if o.sess == nil { o.sess = &fakeSession{} } return o.sess, nil } func (o *fakeOpener) count() int { o.mu.Lock() defer o.mu.Unlock() return len(o.opens) } // --- harness ----------------------------------------------------------------- type harness struct { server *Server repos *fakeRepos opener *fakeOpener } func newHarness(t *testing.T) *harness { t.Helper() repos, opener := newFakeRepos(), &fakeOpener{} srv, err := New(Options{Repos: repos, Browse: opener}) require.NoError(t, err) return &harness{server: srv, repos: repos, opener: opener} } // gqlResponse is the GraphQL envelope: data and errors travel together, and a // resolver that refused shows up in errors with data still present. type gqlResponse struct { Data json.RawMessage `json:"data"` Errors []struct { Message string `json:"message"` Path []any `json:"path"` } `json:"errors"` } // query POSTs a query as caller (nil for anonymous) and returns the raw // recorder plus the decoded envelope. func (h *harness) query(t *testing.T, caller *auth.AuthContext, q string) (*httptest.ResponseRecorder, gqlResponse) { t.Helper() body, err := json.Marshal(map[string]any{"query": q}) require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") if caller != nil { req = req.WithContext(authn.WithCaller(req.Context(), caller)) } rec := httptest.NewRecorder() h.server.ServeHTTP(rec, req) var out gqlResponse if rec.Body.Len() > 0 && strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), "body: %s", rec.Body.String()) } return rec, out } // ok asserts the query succeeded and unmarshals data into v. func (h *harness) ok(t *testing.T, caller *auth.AuthContext, q string, v any) { t.Helper() rec, resp := h.query(t, caller, q) require.Equal(t, http.StatusOK, rec.Code) require.Empty(t, resp.Errors, "unexpected GraphQL errors: %+v", resp.Errors) require.NoError(t, json.Unmarshal(resp.Data, v)) } func testCaller(id int, name string) *auth.AuthContext { return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER} } // seed is one instance: alice owns a public, an unlisted and a private // database; bob owns a public one and holds RO on alice's private one. func (h *harness) seed() { h.repos.add(&core.Repo{Name: "widgets", OwnerID: 1, OwnerName: "alice", Description: "public things", Visibility: core.VisibilityPublic}) h.repos.add(&core.Repo{Name: "drafts", OwnerID: 1, OwnerName: "alice", Visibility: core.VisibilityUnlisted}) priv := h.repos.add(&core.Repo{Name: "secrets", OwnerID: 1, OwnerName: "alice", Visibility: core.VisibilityPrivate}) h.repos.add(&core.Repo{Name: "gadgets", OwnerID: 2, OwnerName: "bob", Visibility: core.VisibilityPublic}) h.repos.grant(priv.ID, 2, core.AccessRO, "bob") h.repos.names[1] = "alice" } // --- the listing -------------------------------------------------------------- // The query that started this: what an anonymous caller and each user see from // the same endpoint. The listing rule is the store's, and the schema must not // widen it. func TestDatabasesListsWhatTheCallerMaySee(t *testing.T) { type listing struct { Databases struct { Results []struct { Name string `json:"name"` Visibility string `json:"visibility"` Owner struct { Username string `json:"username"` CanonicalName string `json:"canonicalName"` } `json:"owner"` } `json:"results"` Cursor *string `json:"cursor"` } `json:"databases"` } const q = `{ databases { results { name visibility owner { username canonicalName } } cursor } }` for _, tc := range []struct { name string caller *auth.AuthContext want []string }{ {"anonymous sees only PUBLIC", nil, []string{"gadgets", "widgets"}}, {"a stranger sees the same as anonymous", testCaller(9, "carol"), []string{"gadgets", "widgets"}}, {"the owner sees all of their own", testCaller(1, "alice"), []string{"gadgets", "secrets", "drafts", "widgets"}}, {"an ACL holder additionally sees what they were granted", testCaller(2, "bob"), []string{"gadgets", "secrets", "widgets"}}, } { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.seed() var got listing h.ok(t, tc.caller, q, &got) var names []string for _, r := range got.Databases.Results { names = append(names, r.Name) } assert.Equal(t, tc.want, names) assert.Nil(t, got.Databases.Cursor, "one page holds this instance") for _, r := range got.Databases.Results { assert.Equal(t, "~"+r.Owner.Username, r.Owner.CanonicalName) } }) } } // A listing must not open a single bare store. This is what the hand-written // model and the resolver:true fields in gqlgen.yml buy, and it is the kind of // thing that regresses silently the day someone adds a field to the struct. func TestAMetadataQueryOpensNoStore(t *testing.T) { h := newHarness(t) h.seed() var got struct{} h.ok(t, testCaller(1, "alice"), `{ databases { results { name visibility description created } } }`, &got) assert.Zero(t, h.opener.count(), "a metadata-only query opened a store") } func TestDatabasesByOwner(t *testing.T) { h := newHarness(t) h.seed() var got struct { DatabasesByOwner struct { Results []struct { Name string `json:"name"` } `json:"results"` } `json:"databasesByOwner"` } h.ok(t, nil, `{ databasesByOwner(owner: "alice") { results { name } } }`, &got) require.Len(t, got.DatabasesByOwner.Results, 1) assert.Equal(t, "widgets", got.DatabasesByOwner.Results[0].Name) } // --- one database ------------------------------------------------------------ // A database the caller may not see is null and not an error: the two answers // are one, so its existence cannot be read out of the shape of the refusal. func TestDatabaseHidesWhatTheCallerMayNotSee(t *testing.T) { const q = `{ database(owner: "alice", name: "%s") { name visibility } }` for _, tc := range []struct { name string caller *auth.AuthContext db string want bool }{ {"a private database is invisible to a stranger", testCaller(9, "carol"), "secrets", false}, {"and to an anonymous caller", nil, "secrets", false}, {"an unlisted one is readable by direct address", nil, "drafts", true}, {"the owner reads their private one", testCaller(1, "alice"), "secrets", true}, {"an ACL holder reads it too", testCaller(2, "bob"), "secrets", true}, {"a database that does not exist", testCaller(1, "alice"), "nope", false}, } { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.seed() rec, resp := h.query(t, tc.caller, fmt.Sprintf(q, tc.db)) require.Equal(t, http.StatusOK, rec.Code) require.Empty(t, resp.Errors, "a refusal must be null, not an error: %+v", resp.Errors) var got struct { Database *struct { Name string `json:"name"` } `json:"database"` } require.NoError(t, json.Unmarshal(resp.Data, &got)) if tc.want { require.NotNil(t, got.Database) assert.Equal(t, tc.db, got.Database.Name) } else { assert.Nil(t, got.Database) } }) } } // The ACL of a database you do not own is not yours to enumerate. It is the // empty list rather than an error, so asking is not a way to learn who is on it. func TestACLIsOwnerOnly(t *testing.T) { const q = `{ database(owner: "alice", name: "secrets") { acl { mode user { username } } } }` h := newHarness(t) h.seed() var owner struct { Database struct { ACL []struct { Mode string `json:"mode"` User struct { Username string `json:"username"` } `json:"user"` } `json:"acl"` } `json:"database"` } h.ok(t, testCaller(1, "alice"), q, &owner) require.Len(t, owner.Database.ACL, 1) assert.Equal(t, "bob", owner.Database.ACL[0].User.Username) assert.Equal(t, "RO", owner.Database.ACL[0].Mode) // bob may READ the database — he holds the grant — and still may not see // who else does. var grantee struct { Database struct { ACL []json.RawMessage `json:"acl"` } `json:"database"` } h.ok(t, testCaller(2, "bob"), q, &grantee) assert.Empty(t, grantee.Database.ACL) } func TestMeIsTheCallerOrNull(t *testing.T) { h := newHarness(t) h.seed() var anon struct { Me *struct{} `json:"me"` } h.ok(t, nil, `{ me { username } }`, &anon) assert.Nil(t, anon.Me, "anonymous is an answer here, not an error") var known struct { Me struct { Username string `json:"username"` CanonicalName string `json:"canonicalName"` } `json:"me"` } h.ok(t, testCaller(1, "alice"), `{ me { username canonicalName } }`, &known) assert.Equal(t, "alice", known.Me.Username) assert.Equal(t, "~alice", known.Me.CanonicalName) } // --- the browse fields -------------------------------------------------------- func TestBranchesLogAndTables(t *testing.T) { h := newHarness(t) h.seed() when := time.Date(2026, 8, 14, 9, 30, 0, 0, time.UTC) h.opener.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abc"}, {Name: "topic", Head: "def"}}, commits: []browse.CommitInfo{{ Hash: "abc", Author: "alice", Email: "a@b.test", Date: when, Message: "init", ParentHashes: []string{"aaa"}, }}, next: "older", tables: []browse.TableInfo{{ Name: "issues", RowCount: 7, Columns: []browse.ColumnInfo{{Name: "id", Type: "int", PrimaryKey: true}}, }}, } var got struct { Database struct { DefaultBranch string `json:"defaultBranch"` Branches []struct { Name string `json:"name"` Head string `json:"head"` } `json:"branches"` Log struct { Results []struct { Hash string `json:"hash"` Author string `json:"author"` Date time.Time `json:"date"` Parents []string `json:"parents"` } `json:"results"` Cursor *string `json:"cursor"` } `json:"log"` Tables []struct { Name string `json:"name"` RowCount int `json:"rowCount"` Columns []struct { Name string `json:"name"` PrimaryKey bool `json:"primaryKey"` } `json:"columns"` } `json:"tables"` } `json:"database"` } h.ok(t, nil, `{ database(owner: "alice", name: "widgets") { defaultBranch branches { name head } log { results { hash author date parents } cursor } tables { name rowCount columns { name primaryKey } } } }`, &got) assert.Equal(t, "main", got.Database.DefaultBranch) require.Len(t, got.Database.Branches, 2) assert.Equal(t, "abc", got.Database.Branches[0].Head) require.Len(t, got.Database.Log.Results, 1) assert.Equal(t, when, got.Database.Log.Results[0].Date) assert.Equal(t, []string{"aaa"}, got.Database.Log.Results[0].Parents) assert.NotNil(t, got.Database.Log.Cursor, "browse reported more history, so the page must carry a cursor") require.Len(t, got.Database.Tables, 1) assert.Equal(t, 7, got.Database.Tables[0].RowCount) assert.True(t, got.Database.Tables[0].Columns[0].PrimaryKey) // The default branch is what a field with no ref reads. assert.Equal(t, "main", h.opener.sess.logRef) assert.Equal(t, defaultLogLimit, h.opener.sess.logLimit) assert.True(t, h.opener.sess.closed, "every session a resolver opens is closed") } // A database nothing has been pushed to has no branches. That is a state, not a // failure, and every field says so in its own vocabulary rather than erroring. func TestAnEmptyDatabaseIsAStateAndNotAFailure(t *testing.T) { h := newHarness(t) h.seed() h.opener.sess = &fakeSession{} // no branches, no commits, no tables var got struct { Database struct { DefaultBranch *string `json:"defaultBranch"` Branches []any `json:"branches"` Log struct { Results []any `json:"results"` Cursor *string `json:"cursor"` } `json:"log"` Tables []any `json:"tables"` } `json:"database"` } h.ok(t, nil, `{ database(owner: "alice", name: "widgets") { defaultBranch branches { name } log { results { hash } cursor } tables { name } } }`, &got) assert.Nil(t, got.Database.DefaultBranch) assert.Empty(t, got.Database.Branches) assert.Empty(t, got.Database.Log.Results) assert.Nil(t, got.Database.Log.Cursor) assert.Empty(t, got.Database.Tables) } // A store that will not open is an error on that field — the database exists and // the caller may read it — and the store layer's own text, which carries the // on-disk path, never travels with it. func TestAStoreThatWillNotOpenIsAnErrorAndSaysNothingAboutDisk(t *testing.T) { h := newHarness(t) h.seed() h.opener.openErr = errors.New("open /var/lib/dolt/~alice/widgets: manifest is corrupt") rec, resp := h.query(t, nil, `{ database(owner: "alice", name: "widgets") { name branches { name } } }`) require.Equal(t, http.StatusOK, rec.Code) require.NotEmpty(t, resp.Errors) body := rec.Body.String() assert.Contains(t, body, "could not be read") assert.NotContains(t, body, "/var/lib/dolt", "the store's path must not reach the reader") assert.NotContains(t, body, "manifest is corrupt") } // The metadata store being unreachable is not "there is nothing here". A // listing that answered an empty set on a failed query would tell a client a // false fact about the instance. func TestAnUnreachableStoreIsAnErrorAndNotAnEmptyListing(t *testing.T) { h := newHarness(t) h.seed() h.repos.listErr = errors.New("dial tcp 10.0.0.5:5432: connect: connection refused") rec, resp := h.query(t, nil, `{ databases { results { name } } }`) require.Equal(t, http.StatusOK, rec.Code) require.NotEmpty(t, resp.Errors) assert.NotContains(t, rec.Body.String(), "10.0.0.5", "the connection string must not reach the reader") } // An ACL lookup that fails is an error, not a fall-through to visibility: a // caller holding a grant must never be told the database is not there because // the grant could not be checked. func TestAnUncheckableGrantIsNotADenial(t *testing.T) { h := newHarness(t) h.seed() h.repos.accessErr = errors.New("dial tcp: connection refused") rec, resp := h.query(t, testCaller(2, "bob"), `{ database(owner: "alice", name: "secrets") { name } }`) require.Equal(t, http.StatusOK, rec.Code) require.NotEmpty(t, resp.Errors, "an unreadable grant answered as 'no such database'") } // --- paging ------------------------------------------------------------------- // The cursor is opaque and encrypted, so a test walks it exactly as a client // does: read a page, hand the cursor back, and check the pages tile the listing // without gaps or repeats. func TestTheListingPagesWithItsOwnCursor(t *testing.T) { h := newHarness(t) for i := range 5 { h.repos.add(&core.Repo{ Name: fmt.Sprintf("db%d", i), OwnerID: 1, OwnerName: "alice", Visibility: core.VisibilityPublic, }) } type pageResp struct { Databases struct { Results []struct { Name string `json:"name"` } `json:"results"` Cursor *string `json:"cursor"` } `json:"databases"` } var got pageResp h.ok(t, nil, `{ databases { results { name } cursor } }`, &got) require.Len(t, got.Databases.Results, 5, "the default page holds this instance") assert.Nil(t, got.Databases.Cursor) // Walk it two at a time, exactly as a client does: the page size is given // once, and every later page is addressed by the cursor the previous one // returned. var seen []string var pages int h.ok(t, nil, `{ databases(filter: {count: 2}) { results { name } cursor } }`, &got) for { pages++ require.LessOrEqual(t, len(got.Databases.Results), 2, "a page overran the count it was given") for _, r := range got.Databases.Results { seen = append(seen, r.Name) } if got.Databases.Cursor == nil { break } require.Less(t, pages, 10, "the walk did not terminate") h.ok(t, nil, fmt.Sprintf(`{ databases(cursor: %q) { results { name } cursor } }`, *got.Databases.Cursor), &got) } assert.Equal(t, 3, pages, "five rows, two at a time") assert.Equal(t, []string{"db4", "db3", "db2", "db1", "db0"}, seen, "the pages tile the listing, newest first, with no gap and no repeat") } // The count is the surface's to cap. A client asking for the whole instance in // one page gets the cap, not the ask. func TestThePageSizeIsCapped(t *testing.T) { h := newHarness(t) for i := range maxPageSize + 10 { h.repos.add(&core.Repo{ Name: fmt.Sprintf("db%d", i), OwnerID: 1, OwnerName: "alice", Visibility: core.VisibilityPublic, }) } var got struct { Databases struct { Results []struct { Name string `json:"name"` } `json:"results"` Cursor *string `json:"cursor"` } `json:"databases"` } h.ok(t, nil, `{ databases(filter: {count: 100000}) { results { name } cursor } }`, &got) assert.Len(t, got.Databases.Results, maxPageSize) assert.NotNil(t, got.Databases.Cursor, "the rest is still reachable, one page at a time") } // A cursor whose row is gone — deleted, or made invisible to this caller — // resumes at the next row rather than failing or silently starting over. func TestAStaleCursorResumesRatherThanRestarting(t *testing.T) { h := newHarness(t) for i := range 4 { h.repos.add(&core.Repo{ Name: fmt.Sprintf("db%d", i), OwnerID: 1, OwnerName: "alice", Visibility: core.VisibilityPublic, }) } var got struct { Databases struct { Results []struct { Name string `json:"name"` } `json:"results"` Cursor *string `json:"cursor"` } `json:"databases"` } h.ok(t, nil, `{ databases(filter: {count: 2}) { results { name } cursor } }`, &got) require.NotNil(t, got.Databases.Cursor) next := *got.Databases.Cursor // db1 is the row that cursor points at; drop it between the two pages. delete(h.repos.repos, "alice/db1") kept := h.repos.order[:0] for _, r := range h.repos.order { if r.Name != "db1" { kept = append(kept, r) } } h.repos.order = kept h.ok(t, nil, fmt.Sprintf(`{ databases(cursor: %q) { results { name } cursor } }`, next), &got) var names []string for _, r := range got.Databases.Results { names = append(names, r.Name) } assert.Equal(t, []string{"db0"}, names, "the page resumes past the vanished row instead of restarting at the newest") } // --- the credential plane ----------------------------------------------------- // A credential this endpoint cannot verify is refused, and refused with the // challenge, so a client knows what to present. This is /mcp's middleware and // the arms are the same. func TestABadTokenIsRefusedWithAChallenge(t *testing.T) { h := newHarness(t) req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(`{"query":"{ __typename }"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer not-a-real-token") rec := httptest.NewRecorder() h.server.ServeHTTP(rec, req) require.Equal(t, http.StatusUnauthorized, rec.Code) assert.NotEmpty(t, rec.Header().Get("WWW-Authenticate")) assert.Contains(t, rec.Body.String(), "refused") } // Introspection is deliberately on: a client that cannot introspect cannot // generate a typed client, and every field is gated per caller anyway. func TestIntrospectionAnswers(t *testing.T) { h := newHarness(t) var got struct { Schema struct { QueryType struct { Name string `json:"name"` } `json:"queryType"` } `json:"__schema"` } h.ok(t, nil, `{ __schema { queryType { name } } }`, &got) assert.Equal(t, "Query", got.Schema.QueryType.Name) } // A GET is not a transport this endpoint offers: a query in a URL is a // cross-origin-readable address for data that is often private. func TestGetIsNotATransport(t *testing.T) { h := newHarness(t) rec := httptest.NewRecorder() h.server.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/query?query={__typename}", nil)) assert.NotEqual(t, http.StatusOK, rec.Code) } func TestNewRequiresItsSeams(t *testing.T) { _, err := New(Options{Browse: &fakeOpener{}}) require.Error(t, err) assert.Contains(t, err.Error(), "Repos") _, err = New(Options{Repos: newFakeRepos()}) require.Error(t, err) assert.Contains(t, err.Error(), "BrowseOpener") }