package web import ( "context" "crypto/rand" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/dolthub/dolt/go/libraries/doltcore/creds" "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-ecore/csrf" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" "sourcecraft.dev/bigbes/sr-ht-ecore/pages" "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" ) // selfOrigin is what the synthetic instance config gives this service, and so // what the chrome and the same-origin guard read as ours. var selfOrigin = ecoretest.Origin(serviceName) // --- fakes ------------------------------------------------------------------- type fakeStore struct { repos map[string]*core.Repo // key "owner/name" byID map[int]*core.Repo acls map[int]map[int]core.AccessMode // repoID -> userID -> mode keys map[int][]*db.DoltKey // userID -> keys nextID int nextKeyID int createErr error // listErr, when set, makes ListReposForViewer fail — the metadata store // unreachable, which the pages that enumerate databases have to survive. listErr error // getErr, when set, makes GetRepoByOwnerAndName fail with it instead of // answering — the metadata store unreachable rather than the row missing, // which the pages that resolve one database must not confuse with a // database that does not exist. getErr error createdCalls []*core.Repo deletedRepos []int } func newFakeStore() *fakeStore { return &fakeStore{ repos: map[string]*core.Repo{}, byID: map[int]*core.Repo{}, acls: map[int]map[int]core.AccessMode{}, keys: map[int][]*db.DoltKey{}, nextID: 1, nextKeyID: 1, } } func (f *fakeStore) add(r *core.Repo) *core.Repo { r.ID = f.nextID f.nextID++ f.repos[r.OwnerName+"/"+r.Name] = r f.byID[r.ID] = r return r } func (f *fakeStore) CreateRepo(_ context.Context, r *core.Repo) (*core.Repo, error) { if f.createErr != nil { return nil, f.createErr } if _, ok := f.repos[r.OwnerName+"/"+r.Name]; ok { return nil, db.ErrNameTaken } cp := *r out := f.add(&cp) f.createdCalls = append(f.createdCalls, out) return out, nil } func (f *fakeStore) 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 } func (f *fakeStore) ListReposByOwner(_ context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) { var out []*core.Repo for _, r := range f.repos { if r.OwnerName != owner { continue } visible := r.Visibility == core.VisibilityPublic if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) { visible = true } if visible { out = append(out, r) } } return out, nil } // ListReposForViewer mirrors db.Store's instance-wide listing rule: PUBLIC to // everyone, plus whatever the viewer owns or holds an ACL on. Sorted by id so a // test that depends on the order it hands to /ready gets the same one twice. func (f *fakeStore) 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.byID { visible := r.Visibility == core.VisibilityPublic if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) { visible = true } if visible { out = append(out, r) } } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (f *fakeStore) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) { var out []*core.Repo for _, r := range f.byID { if r.OwnerID == userID || f.hasACL(r.ID, userID) { out = append(out, r) } } return out, nil } func (f *fakeStore) UpdateRepo(_ context.Context, id int, description string, visibility core.Visibility) error { r, ok := f.byID[id] if !ok { return db.ErrNotFound } r.Description = description r.Visibility = visibility return nil } func (f *fakeStore) DeleteRepo(_ context.Context, id int) error { r, ok := f.byID[id] if !ok { return db.ErrNotFound } delete(f.byID, id) delete(f.repos, r.OwnerName+"/"+r.Name) f.deletedRepos = append(f.deletedRepos, id) return nil } func (f *fakeStore) hasACL(repoID, userID int) bool { m, ok := f.acls[repoID] if !ok { return false } _, ok = m[userID] return ok } func (f *fakeStore) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) { m, ok := f.acls[repoID] if !ok { return nil, nil } mode, ok := m[userID] if !ok { return nil, nil } return &mode, nil } func (f *fakeStore) 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: fmt.Sprintf("user%d", uid), Mode: mode}) } return out, nil } func (f *fakeStore) UpsertACL(_ context.Context, repoID, userID int, mode core.AccessMode) error { if f.acls[repoID] == nil { f.acls[repoID] = map[int]core.AccessMode{} } f.acls[repoID][userID] = mode return nil } func (f *fakeStore) DeleteACL(_ context.Context, repoID, userID int) error { if !f.hasACL(repoID, userID) { return db.ErrNotFound } delete(f.acls[repoID], userID) return nil } func (f *fakeStore) InsertKey(_ context.Context, userID int, kid string, pubkey []byte, comment string) (*db.DoltKey, error) { for _, ks := range f.keys { for _, k := range ks { if k.KID == kid { return nil, db.ErrKeyExists } } } k := &db.DoltKey{ID: f.nextKeyID, UserID: userID, KID: kid, PubKey: pubkey, Comment: comment, Created: time.Now()} f.nextKeyID++ f.keys[userID] = append(f.keys[userID], k) return k, nil } func (f *fakeStore) ListKeysByUser(_ context.Context, userID int) ([]*db.DoltKey, error) { return f.keys[userID], nil } func (f *fakeStore) DeleteKey(_ context.Context, id, userID int) error { ks := f.keys[userID] for i, k := range ks { if k.ID == id { f.keys[userID] = append(ks[:i], ks[i+1:]...) return nil } } return db.ErrNotFound } type fakeStoreManager struct { initErr error initCalls []string deleteCalls []string evictCalls []string } func (m *fakeStoreManager) InitStore(_ context.Context, absPath, _, _ string) error { m.initCalls = append(m.initCalls, absPath) return m.initErr } func (m *fakeStoreManager) DeleteStore(_ context.Context, _, absPath string) error { m.deleteCalls = append(m.deleteCalls, absPath) return nil } func (m *fakeStoreManager) Evict(diskPath string) error { m.evictCalls = append(m.evictCalls, diskPath) return nil } type fakeSession struct { branches []browse.Branch commits []browse.CommitInfo tables []browse.TableInfo rows *browse.RowPage // rowsByTable, when set, lets a test return a distinct page per table name // (as the beads view needs). A named miss falls back to rows. A table absent // from a non-nil map is reported as ErrTableNotFound, mirroring the store. rowsByTable map[string]*browse.RowPage // rowsByRef is rowsByTable per ref (ref → table → page), for the Memory // view's revision walk: it reads the same table at several commits and the // whole point is that the content differs between them. A ref absent here // falls through to rowsByTable, so every other fixture is unaffected. rowsByRef map[string]map[string]*browse.RowPage // tableHashes is the content hash of a table at a ref, keyed "/". // An absent entry is the store's answer for a table that does not exist // there — which for the walk means "unchanged from the equally-absent // neighbour", so a fixture that sets none skips every commit. tableHashes map[string]string summary *browse.CommitDiff // logErr, when set, makes Log fail — a store whose history cannot be read, // which every page reading the log for decoration has to survive. logErr error closed bool // Read counters. The /ready page's head-hash gate is a claim about reads not // happening, and the only way to check that is to count them: a timing // measurement would pass on a fast machine whatever the code did. rowReads int tableReads int logReads int opens int } func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil } func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) { s.logReads++ if s.logErr != nil { return nil, "", s.logErr } return s.commits, "", nil } func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) { s.tableReads++ return s.tables, nil } func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) { h, ok := s.tableHashes[refStr+"/"+table] return h, ok, nil } func (s *fakeSession) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) { s.rowReads++ if byTable, ok := s.rowsByRef[ref]; ok { if p, ok := byTable[table]; ok { return p, nil } return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) } if s.rowsByTable != nil { if p, ok := s.rowsByTable[table]; ok { return p, nil } return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) } return s.rows, nil } func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.CommitDiff, error) { return s.summary, nil } func (s *fakeSession) Close() error { s.closed = true; return nil } type fakeBrowse struct { sess *fakeSession // byPath is a session per store path, for the pages that open more than one // database in a request (/ready). A path absent here falls back to sess, so // every single-database test is unaffected. byPath map[string]*fakeSession // errByPath is a store that refuses to open, keyed the same way. errByPath map[string]error } func (b *fakeBrowse) Open(_ context.Context, path string) (BrowseSession, error) { if err, ok := b.errByPath[path]; ok { return nil, err } if s, ok := b.byPath[path]; ok { s.opens++ return s, nil } if b.sess == nil { return &fakeSession{}, nil } b.sess.opens++ return b.sess, nil } type fakeUsers struct { byName map[string]*core.Caller } func (u *fakeUsers) LookupUser(_ context.Context, username string) (*core.Caller, error) { c, ok := u.byName[username] if !ok { return nil, errors.New("no such user") } return c, nil } // --- harness ----------------------------------------------------------------- type harness struct { router chi.Router app *app store *fakeStore stores *fakeStoreManager browse *fakeBrowse users *fakeUsers } func newHarness(t *testing.T) *harness { t.Helper() return newHarnessWithStatic(t, "") } // newHarnessWithStatic is newHarness for the tests that need a real static // tree on disk — the asset routes, and the stylesheet the layout links. func newHarnessWithStatic(t *testing.T, staticDir string) *harness { t.Helper() store := newFakeStore() stores := &fakeStoreManager{} fb := &fakeBrowse{} users := &fakeUsers{byName: map[string]*core.Caller{}} cfg := Config{ Conf: ecoretest.Config(serviceName), ReposRoot: "/var/lib/dolt", StaticDir: staticDir, Stores: stores, Repos: store, Browse: fb, Users: users, RepoDiskPath: func(owner, name string) string { return "/var/lib/dolt/~" + owner + "/" + name }, } r := chi.NewRouter() a, err := newApp(cfg) if err != nil { t.Fatalf("newApp: %v", err) } a.mount(r) return &harness{router: r, app: a, store: store, stores: stores, browse: fb, users: users} } // do issues a request through the router, optionally with an authenticated // caller injected into the context (as OptionalCookieMiddleware would). func (h *harness) do(method, target string, caller *auth.AuthContext, form url.Values) *httptest.ResponseRecorder { var req *http.Request if form != nil { req = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", selfOrigin) // same-origin by default } else { req = httptest.NewRequest(method, target, nil) } if caller != nil { req = req.WithContext(authn.WithCaller(req.Context(), caller)) } rec := httptest.NewRecorder() h.router.ServeHTTP(rec, req) return rec } func testCaller(id int, name string) *auth.AuthContext { return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER, Email: name + "@example.com"} } // validDoltPubKeyStr returns a 52-char base32 dolt public key (over 32 random // bytes) in dolt's custom alphabet, exactly the shape `dolt login` emits. func validDoltPubKeyStr(t *testing.T) string { t.Helper() pub := make([]byte, ed25519PubKeyLen) if _, err := rand.Read(pub); err != nil { t.Fatalf("rand: %v", err) } return creds.B32CredsEncoding.EncodeToString(pub) } // --- tests ------------------------------------------------------------------- func TestOverviewAnonymousPublicPrivate(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "pub", OwnerID: 1, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPublic}) h.store.add(&core.Repo{Name: "sec", OwnerID: 1, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate}) if rec := h.do("GET", "/~alice/pub", nil, nil); rec.Code != http.StatusOK { t.Fatalf("public overview: got %d, want 200", rec.Code) } rec := h.do("GET", "/~alice/sec", nil, nil) if rec.Code != http.StatusNotFound { t.Fatalf("private overview anon: got %d, want 404", rec.Code) } if strings.Contains(rec.Body.String(), "sec") && strings.Contains(rec.Body.String(), "clone") { t.Fatalf("private repo leaked details to anonymous") } } func TestPrivateVisibleToOwner(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "sec", OwnerID: 7, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate}) rec := h.do("GET", "/~alice/sec", testCaller(7, "alice"), nil) if rec.Code != http.StatusOK { t.Fatalf("owner private overview: got %d, want 200", rec.Code) } } // storeOutage is the shape of a metadata store that cannot answer: not a miss, // and carrying a host and a port the reader has no business seeing. var storeOutage = errors.New("dial tcp 10.0.0.5:5432: connect: connection refused") // A database whose metadata row cannot be *read* is not a database that does not // exist. Reporting the outage as a 404 tells every reader on the instance that // their database is gone for as long as Postgres is down — and, being a 404 with // the shared sentence, tells them so in the voice reserved for "there is nothing // here". func TestAStoreOutageIsNotAMissingDatabase(t *testing.T) { for _, tc := range []struct { name, target string caller *auth.AuthContext }{ {"overview", "/~alice/db", nil}, {"log", "/~alice/db/log", nil}, {"tree", "/~alice/db/tree/main", nil}, {"table", "/~alice/db/table/main/things", nil}, {"view", "/~alice/db/view/beads", nil}, // The admin path resolves the same row through loadRepoForAdmin and must // classify it the same way. {"settings", "/~alice/db/settings", testCaller(1, "alice")}, } { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.store.getErr = storeOutage rec := h.do("GET", tc.target, tc.caller, nil) require.Equal(t, http.StatusInternalServerError, rec.Code) body := rec.Body.String() assert.Contains(t, body, pages.InternalMessage) assert.NotContains(t, body, "connection refused", "the cause belongs in the log") assert.NotContains(t, body, "10.0.0.5:5432") assert.NotContains(t, body, pages.NotFoundMessage) }) } } // The other arm, and the one the masking rule rests on: the store's own "no such // row" — including a wrapped one, which is how db/ hands it up — is still the // 404, and still the *same* 404 a PRIVATE database the caller may not see gets. func TestAMissingDatabaseIsStillNotFound(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "sec", OwnerID: 1, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate}) // The masked PRIVATE database, and the same URL with no row behind it at // all. One URL for both so the two responses are comparable byte for byte: // the shared error page carries the request's own path in the login link. hidden := h.do("GET", "/~alice/sec", nil, nil) require.Equal(t, http.StatusNotFound, hidden.Code) assert.Contains(t, hidden.Body.String(), pages.NotFoundMessage) // A miss the store wrapped on its way up, which errors.Is must still see // through — db/ wraps its misses with the owner and name it looked up. h.store.getErr = fmt.Errorf("db: get repo ~alice/sec: %w", db.ErrNotFound) missing := h.do("GET", "/~alice/sec", nil, nil) require.Equal(t, http.StatusNotFound, missing.Code) assert.Equal(t, hidden.Body.String(), missing.Body.String(), "a database somebody may not see and one that is not there must render the same page") } func TestDashboardLists(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "mine", OwnerID: 3, OwnerName: "bob", Path: "/m", Visibility: core.VisibilityPrivate}) rec := h.do("GET", "/", testCaller(3, "bob"), nil) if rec.Code != http.StatusOK { t.Fatalf("dashboard: got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "~bob/mine") { t.Fatalf("dashboard missing owned repo; body=%s", rec.Body.String()) } if !strings.Contains(rec.Body.String(), "/create") { t.Fatalf("dashboard missing create link") } // Anonymous dashboard shows the blurb, not the list. anon := h.do("GET", "/", nil, nil) if !strings.Contains(anon.Body.String(), "Log in") { t.Fatalf("anon dashboard missing login blurb") } } func TestCreateValidationAndSuccess(t *testing.T) { h := newHarness(t) caller := testCaller(5, "carol") // Anonymous create is redirected to login. if rec := h.do("GET", "/create", nil, nil); rec.Code != http.StatusSeeOther { t.Fatalf("anon create form: got %d, want 303", rec.Code) } // Invalid name. bad := h.do("POST", "/create", caller, url.Values{"name": {"bad name!"}, "visibility": {"PUBLIC"}}) if bad.Code != http.StatusBadRequest { t.Fatalf("invalid name: got %d, want 400", bad.Code) } // Success. ok := h.do("POST", "/create", caller, url.Values{"name": {"gooddb"}, "visibility": {"PUBLIC"}, "description": {"hi"}}) if ok.Code != http.StatusSeeOther { t.Fatalf("create success: got %d, want 303; body=%s", ok.Code, ok.Body.String()) } if got := ok.Header().Get("Location"); got != "/~carol/gooddb" { t.Fatalf("create redirect: got %q", got) } if len(h.stores.initCalls) != 1 || h.stores.initCalls[0] != "/var/lib/dolt/~carol/gooddb" { t.Fatalf("InitStore not called correctly: %v", h.stores.initCalls) } if _, err := h.store.GetRepoByOwnerAndName(context.Background(), "carol", "gooddb"); err != nil { t.Fatalf("repo row not created: %v", err) } } func TestCreateStoreFailureRollsBackRow(t *testing.T) { h := newHarness(t) h.stores.initErr = errors.New("disk full") caller := testCaller(5, "carol") rec := h.do("POST", "/create", caller, url.Values{"name": {"gooddb"}, "visibility": {"PUBLIC"}}) if rec.Code != http.StatusInternalServerError { t.Fatalf("create with store failure: got %d, want 500", rec.Code) } if _, err := h.store.GetRepoByOwnerAndName(context.Background(), "carol", "gooddb"); !errors.Is(err, db.ErrNotFound) { t.Fatalf("orphan repo row survived store failure: %v", err) } if len(h.store.deletedRepos) != 1 { t.Fatalf("row not rolled back: %v", h.store.deletedRepos) } } // The same-origin guard is sr-ht-ecore's csrf middleware on the browser group, // and no longer three per-handler calls. What is ours to test is that it is // mounted over every mutating route — including the one whose handler used to // carry the check and now does not — and that a request refusing to say where // it came from is refused rather than waved through. func TestMutationsAreRefusedWithoutSameOriginEvidence(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) caller := testCaller(10, "owner") post := func(target, origin string, form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest("POST", target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if origin != "" { req.Header.Set("Origin", origin) } req = req.WithContext(authn.WithCaller(req.Context(), caller)) rec := httptest.NewRecorder() h.router.ServeHTTP(rec, req) return rec } for _, tc := range []struct{ name, target string }{ {"create", "/create"}, {"keys", "/settings/keys"}, {"settings", "/~owner/db/settings"}, } { t.Run(tc.name+" cross-origin", func(t *testing.T) { rec := post(tc.target, "https://evil.example", url.Values{"name": {"x"}, "visibility": {"PUBLIC"}}) require.Equal(t, http.StatusForbidden, rec.Code) assert.Contains(t, rec.Body.String(), csrf.Message) }) t.Run(tc.name+" no headers", func(t *testing.T) { // Neither Origin nor Referer: a request that will not say where it // came from cannot be shown to have come from us. rec := post(tc.target, "", url.Values{"name": {"x"}, "visibility": {"PUBLIC"}}) assert.Equal(t, http.StatusForbidden, rec.Code) }) } } // TestFormsReadTheBodyAndOnlyTheBody pins the two properties every mutation on // this surface now gets from pages.FormValues. // // The query string is not the form. r.Form would merge it into the body's // values, which would let a mutation be driven entirely from a URL somebody was // linked to — and that is exactly the request the same-origin guard sees nothing // wrong with, because it really did come from our own page. // // And the body is bounded. net/http's own ceiling is 10 MiB per request, three // orders of magnitude more than any form here sends. func TestFormsReadTheBodyAndOnlyTheBody(t *testing.T) { post := func(t *testing.T, target string, body io.Reader) *httptest.ResponseRecorder { t.Helper() h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) req := httptest.NewRequest("POST", target, body) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", selfOrigin) req = req.WithContext(authn.WithCaller(req.Context(), testCaller(10, "owner"))) rec := httptest.NewRecorder() h.router.ServeHTTP(rec, req) return rec } t.Run("the query string cannot supply a field", func(t *testing.T) { // A delete driven from the URL: the confirmation the body does not carry // is offered in the query string instead. It must not be read. rec := post(t, "/~owner/db/settings?action=delete&confirm_name=db", strings.NewReader("")) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "Unknown action.") }) t.Run("an oversized body is refused", func(t *testing.T) { huge := "description=" + strings.Repeat("x", pages.DefaultMaxFormBytes+1) rec := post(t, "/~owner/db/settings", strings.NewReader(huge)) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "Malformed form submission.") }) } // Nothing behind the login cookie may be reused for the next viewer: these URLs // say nothing about who the page was rendered for. func TestPagesAreNotCacheable(t *testing.T) { h := newHarness(t) rec := h.do("GET", "/", testCaller(1, "alice"), nil) require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) assert.Equal(t, "Cookie, Authorization", rec.Header().Get("Vary")) } // panicView is a View whose Build panics, which is the only route into a // handler panic this package can reach from a test. type panicView struct{} func (panicView) Name() string { return "boom" } func (panicView) Label() string { return "Boom" } func (panicView) Template() string { return pages.ErrorPage + ".html" } func (panicView) Applies([]browse.TableInfo) bool { return true } func (panicView) Build(context.Context, BrowseSession, *core.Repo, string, url.Values) (any, error) { panic("the store went away") } // A panic before the response has started is answered with the error page every // other bug gets, and the panic value stays in the log. func TestAPanicBecomesTheErrorPage(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: issueTables(), } setViews(t, h, panicView{}) rec := h.do("GET", "/~alice/db/view/boom", nil, nil) require.Equal(t, http.StatusInternalServerError, rec.Code) assert.Contains(t, rec.Body.String(), pages.InternalMessage) assert.NotContains(t, rec.Body.String(), "the store went away") } func TestSettingsOwnerGate(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) // Anonymous → login redirect. if rec := h.do("GET", "/~owner/db/settings", nil, nil); rec.Code != http.StatusSeeOther { t.Fatalf("anon settings: got %d, want 303", rec.Code) } // Non-owner on a PUBLIC repo → 403. if rec := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil); rec.Code != http.StatusForbidden { t.Fatalf("non-owner settings: got %d, want 403", rec.Code) } // Owner → 200. if rec := h.do("GET", "/~owner/db/settings", testCaller(10, "owner"), nil); rec.Code != http.StatusOK { t.Fatalf("owner settings: got %d, want 200", rec.Code) } } func TestSettingsNonOwnerPrivateIs404(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPrivate}) rec := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil) if rec.Code != http.StatusNotFound { t.Fatalf("non-owner private settings: got %d, want 404", rec.Code) } } func TestSettingsUpdateAndDelete(t *testing.T) { h := newHarness(t) repo := h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/var/lib/dolt/~owner/db", Visibility: core.VisibilityPublic}) owner := testCaller(10, "owner") upd := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"update"}, "description": {"new desc"}, "visibility": {"PRIVATE"}}) if upd.Code != http.StatusOK { t.Fatalf("update: got %d", upd.Code) } if repo.Description != "new desc" || repo.Visibility != core.VisibilityPrivate { t.Fatalf("update not applied: %+v", repo) } // Delete requires a matching name confirmation. badDel := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"delete"}, "confirm_name": {"wrong"}}) if badDel.Code != http.StatusBadRequest { t.Fatalf("delete wrong confirm: got %d, want 400", badDel.Code) } del := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"delete"}, "confirm_name": {"db"}}) if del.Code != http.StatusSeeOther { t.Fatalf("delete: got %d, want 303", del.Code) } if len(h.stores.deleteCalls) != 1 || len(h.stores.evictCalls) != 1 { t.Fatalf("store delete/evict not called: del=%v evict=%v", h.stores.deleteCalls, h.stores.evictCalls) } } func TestSettingsACLAddRemove(t *testing.T) { h := newHarness(t) repo := h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) h.users.byName["dave"] = &core.Caller{UserID: 42, Username: "dave"} owner := testCaller(10, "owner") add := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_add"}, "username": {"dave"}, "mode": {"RW"}}) if add.Code != http.StatusOK { t.Fatalf("acl add: got %d; body=%s", add.Code, add.Body.String()) } if !h.store.hasACL(repo.ID, 42) { t.Fatalf("acl not added") } // Unknown user rejected. if bad := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_add"}, "username": {"ghost"}, "mode": {"RO"}}); bad.Code != http.StatusBadRequest { t.Fatalf("acl add unknown user: got %d, want 400", bad.Code) } rm := h.do("POST", "/~owner/db/settings", owner, url.Values{"action": {"acl_remove"}, "user_id": {"42"}}) if rm.Code != http.StatusOK { t.Fatalf("acl remove: got %d", rm.Code) } if h.store.hasACL(repo.ID, 42) { t.Fatalf("acl not removed") } } func TestKeysAddDeleteAndFragmentPage(t *testing.T) { h := newHarness(t) caller := testCaller(11, "keyuser") // The page renders and contains the hash-fragment JS. page := h.do("GET", "/settings/keys", caller, nil) if page.Code != http.StatusOK { t.Fatalf("keys page: got %d", page.Code) } if !strings.Contains(page.Body.String(), "window.location.hash") { t.Fatalf("keys page missing hash-fragment JS") } // Add a key using a valid dolt base32 public key. pub := validDoltPubKeyStr(t) add := h.do("POST", "/settings/keys", caller, url.Values{"pubkey": {pub}, "comment": {"laptop"}}) if add.Code != http.StatusOK { t.Fatalf("key add: got %d; body=%s", add.Code, add.Body.String()) } keys, _ := h.store.ListKeysByUser(context.Background(), 11) if len(keys) != 1 { t.Fatalf("key not stored: %d", len(keys)) } // Invalid key rejected. if bad := h.do("POST", "/settings/keys", caller, url.Values{"pubkey": {"not-base32-!!"}}); bad.Code != http.StatusBadRequest { t.Fatalf("invalid key: got %d, want 400", bad.Code) } // Delete. del := h.do("POST", "/settings/keys", caller, url.Values{"delete_id": {fmt.Sprint(keys[0].ID)}}) if del.Code != http.StatusOK { t.Fatalf("key delete: got %d", del.Code) } if ks, _ := h.store.ListKeysByUser(context.Background(), 11); len(ks) != 0 { t.Fatalf("key not deleted") } } // What the nav contains — which services appear, in what order, which one is // marked active, where the login link points — is sr-ht-ecore's chrome and is // tested there. What is ours is that every page is drawn through it at all, and // that the one page we ask to be full-bleed gets its own wrapper. func TestPagesAreDrawnThroughTheSharedChrome(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, rows: &browse.RowPage{Columns: []string{"id"}, Rows: [][]string{{"1"}}, Total: 1}, } dash := h.do("GET", "/", testCaller(1, "someone"), nil) require.Equal(t, http.StatusOK, dash.Code) body := dash.Body.String() // The switcher and the brand come from the shared partials; the dolt label // is the red service suffix chrome derives from our config section. assert.Contains(t, body, "https://git.example", "shared switcher not rendered") assert.Contains(t, body, `dolt`, "brand label not rendered") // The default page width, which every page but the row browser keeps. assert.Contains(t, body, `
`) rows := h.do("GET", "/~alice/db/table/main/things", nil, nil) require.Equal(t, http.StatusOK, rows.Code) assert.Contains(t, rows.Body.String(), `
`, "the row browser must be full-bleed") } // The 404 and 403 templates this service carried are sr-ht-ecore's error page // now: one body, drawn through our own chrome, with the shared sentence. The // wording matters here — a 404 that described the missing thing would tell an // anonymous viewer which private databases exist. func TestRefusalsRenderTheSharedErrorPage(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 10, OwnerName: "owner", Path: "/d", Visibility: core.VisibilityPublic}) missing := h.do("GET", "/~owner/nosuch", nil, nil) require.Equal(t, http.StatusNotFound, missing.Code) assert.Contains(t, missing.Body.String(), pages.NotFoundMessage) assert.Contains(t, missing.Body.String(), "404 — Not Found") assert.Contains(t, missing.Body.String(), `dolt`, "the error page is drawn through our chrome") denied := h.do("GET", "/~owner/db/settings", testCaller(99, "intruder"), nil) require.Equal(t, http.StatusForbidden, denied.Code) assert.Contains(t, denied.Body.String(), "Only the owner may change database settings.") } // TestRoutingRefusalsRenderTheSharedErrorPage covers the two refusals that never // reach a handler at all — a path this router does not serve, and a method it // does not allow. Both used to fall through to chi's net/http default: plain // text, no chrome, no nav, and the only refusals on this instance that did not // look like the service they came from. func TestRoutingRefusalsRenderTheSharedErrorPage(t *testing.T) { h := newHarness(t) unrouted := h.do("GET", "/no/such/path", nil, nil) require.Equal(t, http.StatusNotFound, unrouted.Code) assert.Contains(t, unrouted.Body.String(), pages.NotFoundMessage) assert.Contains(t, unrouted.Body.String(), `dolt`, "an unrouted URL is answered through our chrome") // POST to a read-only route: routed, but not for this method. badMethod := h.do("POST", "/~alice/anything/log", nil, url.Values{}) require.Equal(t, http.StatusMethodNotAllowed, badMethod.Code) assert.Contains(t, badMethod.Body.String(), pages.MethodMessage) } // TestReadRoutesAnswerHead walks the routing tree and requires every GET route // to be registered for HEAD as well. // // It asks the tree rather than issuing requests because the tree is the record // that matters: a middleware that rewrote the method per request would answer // HEAD while chi's own 405 handler, built out of the methods that were // registered, still said the route accepts GET alone. A read route that answers // `curl -I` with a 405 and a kilobyte of error page is a route no monitor and no // cache can revalidate cheaply. func TestReadRoutesAnswerHead(t *testing.T) { h := newHarness(t) methods := map[string]map[string]bool{} require.NoError(t, chi.Walk(h.router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { if methods[route] == nil { methods[route] = map[string]bool{} } methods[route][method] = true return nil })) require.NotEmpty(t, methods) for route, served := range methods { if served[http.MethodGet] { assert.True(t, served[http.MethodHead], "%s serves GET but not HEAD", route) } } } // TestHeadOnAPrivateDatabaseIsStillNotFound: the HEAD twin shares the GET's // handler, so it cannot answer 200 where the GET answers 404. That equivalence // is what keeps HEAD from becoming a cheap existence oracle for somebody else's // private database (SPEC ch. 6.3). func TestHeadOnAPrivateDatabaseIsStillNotFound(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "sec", OwnerID: 1, OwnerName: "alice", Path: "/s", Visibility: core.VisibilityPrivate}) h.store.add(&core.Repo{Name: "pub", OwnerID: 1, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPublic}) assert.Equal(t, http.StatusNotFound, h.do("HEAD", "/~alice/sec", nil, nil).Code) assert.Equal(t, http.StatusNotFound, h.do("HEAD", "/~alice/nosuch", nil, nil).Code, "a private database and a missing one must be indistinguishable to HEAD too") assert.Equal(t, http.StatusOK, h.do("HEAD", "/~alice/pub", nil, nil).Code) } // leakyView renders a page whose content block reads a field its envelope does // not carry, so executing it fails halfway. It is the shape of the bug the old // renderer turned into a disclosure. type leakyView struct{} func (leakyView) Name() string { return "leaky" } func (leakyView) Label() string { return "Leaky" } func (leakyView) Template() string { return "keys.html" } func (leakyView) Applies([]browse.TableInfo) bool { return true } func (leakyView) Build(context.Context, BrowseSession, *core.Repo, string, url.Values) (any, error) { return nil, nil } // A template that fails halfway answers a fixed sentence. The previous renderer // wrote "template render error: "+err.Error() into the body, which hands the // viewer the template's name and the field path that was not there; the error // belongs in the log and nowhere else. func TestATemplateFailureTellsTheViewerNothing(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: issueTables(), } setViews(t, h, leakyView{}) rec := h.do("GET", "/~alice/db/view/leaky", nil, nil) require.Equal(t, http.StatusInternalServerError, rec.Code) body := rec.Body.String() assert.Equal(t, "internal server error\n", body) assert.NotContains(t, body, "keys.html", "the template name must not reach the viewer") assert.NotContains(t, body, "Keys", "the field path must not reach the viewer") assert.NotContains(t, body, "can't evaluate") } // The static tree is served by sr-ht-ecore's assets handler, which is tested // there. What is ours is that we mounted it: that the hashed stylesheet this // build produced is the one the layout links, that a name whose bytes cannot // change under it is cacheable and one whose bytes can is not, and that // /static/ answers a page rather than an inventory of the build. func TestStaticTreeIsServedWithACachePolicyAndNoListing(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "main.min.0badc0de.css"), []byte("body{}"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "logo.svg"), []byte(""), 0o644)) h := newHarnessWithStatic(t, dir) hashed := h.do("GET", "/static/main.min.0badc0de.css", nil, nil) require.Equal(t, http.StatusOK, hashed.Code) assert.Equal(t, "public, max-age=31536000, immutable", hashed.Header().Get("Cache-Control")) assert.Empty(t, hashed.Header().Get("Vary"), "an immutable asset must not vary on the cookie") unhashed := h.do("GET", "/static/logo.svg", nil, nil) require.Equal(t, http.StatusOK, unhashed.Code) assert.Equal(t, "public, max-age=3600", unhashed.Header().Get("Cache-Control")) listing := h.do("GET", "/static/", nil, nil) assert.Equal(t, http.StatusNotFound, listing.Code, "the static tree must not publish a listing") assert.NotContains(t, listing.Body.String(), `"), 0o644)) shipped := newHarnessWithStatic(t, dir).do("GET", "/", nil, nil) require.Equal(t, http.StatusOK, shipped.Code) assert.Contains(t, shipped.Body.String(), `rel="icon" href="/static/logo.svg"`) bare := newHarnessWithStatic(t, t.TempDir()).do("GET", "/", nil, nil) require.Equal(t, http.StatusOK, bare.Code) assert.Contains(t, bare.Body.String(), `rel="icon" href="data:`, "a build with no logo links the shared data: URI, never a 404") assert.NotContains(t, bare.Body.String(), `href="/static/logo.svg"`) } // The listing partial's Updated and Meta are optional in practice and not only // in ecore's doc comment: this service has no timestamp in its schema, leaves // both zero, and must get a card with no muted footer rather than "0001-01-01". func TestDatabaseListingRendersNoTimestampBlock(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "pub", OwnerID: 1, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPublic}) rec := h.do("GET", "/~alice", nil, nil) require.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.Contains(t, body, "/~alice/pub") assert.NotContains(t, body, "0001-01-01", "a zero Updated must render nothing at all") assert.NotContains(t, body, ``) } func TestLogAndTablePages(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, commits: []browse.CommitInfo{ {Hash: "abcdef1234567890", Author: "alice", Message: "init", Date: time.Now().Add(-2 * time.Hour)}, }, rows: &browse.RowPage{Columns: []string{"id", "name"}, Rows: [][]string{{"1", "x"}}, Total: 1}, } logRec := h.do("GET", "/~alice/db/log", nil, nil) if logRec.Code != http.StatusOK { t.Fatalf("log page: got %d", logRec.Code) } if !strings.Contains(logRec.Body.String(), "abcdef12") || !strings.Contains(logRec.Body.String(), "hours ago") { t.Fatalf("log page missing short hash / reltime; body=%s", logRec.Body.String()) } tblRec := h.do("GET", "/~alice/db/table/main/things", nil, nil) if tblRec.Code != http.StatusOK { t.Fatalf("table page: got %d", tblRec.Code) } // html/template must escape the cell content. if strings.Contains(tblRec.Body.String(), "x") { t.Fatalf("table cell not HTML-escaped") } if !strings.Contains(tblRec.Body.String(), "<b>x</b>") { t.Fatalf("table cell escaping wrong; body=%s", tblRec.Body.String()) } }