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 "<ref>/<table>".
// 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")
}
// browseDetail is a browse failure of the shape the layer really produces: a
// dolt internal, and the store's path on our disk.
const browseDetail = "browse: walk commits: open /var/lib/dolt/~alice/db/.dolt/noms/oldgen: no such file"
// The overview used to render the browse layer's own error text into the page,
// under "Could not read history: ". A reader can do nothing with a chunk store's
// path, and nothing else on this surface discloses one. The page carries a fixed
// sentence and the detail goes to the log.
func TestOverviewDoesNotPrintTheBrowseError(t *testing.T) {
newOverview := func(t *testing.T) *harness {
t.Helper()
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice",
Path: "/var/lib/dolt/~alice/db", Visibility: core.VisibilityPublic})
return h
}
assertHidden := func(t *testing.T, body string) {
t.Helper()
assert.Contains(t, body, "Could not read history.")
assert.NotContains(t, body, browseDetail)
assert.NotContains(t, body, "/var/lib/dolt", "the store's path must not reach the reader")
assert.NotContains(t, body, "walk commits", "dolt's internals must not reach the reader")
}
t.Run("a log that cannot be read", func(t *testing.T) {
h := newOverview(t)
h.browse.sess = &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
logErr: errors.New(browseDetail),
}
rec := h.do("GET", "/~alice/db", nil, nil)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
assertHidden(t, body)
// The rest of the page is still the page: a history we cannot read is
// not a reason to withhold the branches we can.
assert.Contains(t, body, "main")
})
t.Run("a store that cannot be opened", func(t *testing.T) {
h := newOverview(t)
h.browse.errByPath = map[string]error{"/var/lib/dolt/~alice/db": errors.New(browseDetail)}
rec := h.do("GET", "/~alice/db", nil, nil)
require.Equal(t, http.StatusOK, rec.Code)
assertHidden(t, rec.Body.String())
})
// An empty database is a state and not a failure, and the page said so
// before this change too. It must keep saying it, with no warning attached.
t.Run("an empty database is not a failure", func(t *testing.T) {
h := newOverview(t)
h.browse.sess = &fakeSession{branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}}
rec := h.do("GET", "/~alice/db", nil, nil)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
assert.Contains(t, body, "No commits.")
assert.NotContains(t, body, "Could not read history")
})
}
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, `<span class="text-danger">dolt</span>`, "brand label not rendered")
// The default page width, which every page but the row browser keeps.
assert.Contains(t, body, `<div class="container">`)
rows := h.do("GET", "/~alice/db/table/main/things", nil, nil)
require.Equal(t, http.StatusOK, rows.Code)
assert.Contains(t, rows.Body.String(), `<div class="container-fluid">`,
"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(), `<span class="text-danger">dolt</span>`,
"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(), `<span class="text-danger">dolt</span>`,
"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("<svg/>"), 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(), `<a href="logo.svg"`, "no directory entries")
// The hashed name reaches the layout; the dev fallback does not, because
// this tree has a hashed stylesheet.
page := h.do("GET", "/", nil, nil)
require.Equal(t, http.StatusOK, page.Code)
assert.Contains(t, page.Body.String(), `href="/static/main.min.0badc0de.css"`)
}
// A working copy that has only run `make static/main.css` still gets a
// stylesheet; one that has built nothing links none at all rather than an href
// that 404s on every page load.
func TestStylesheetFallsBackToTheUnhashedBuildOnlyWhenItExists(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.css"), []byte("body{}"), 0o644))
dev := newHarnessWithStatic(t, dir).do("GET", "/", nil, nil)
require.Equal(t, http.StatusOK, dev.Code)
assert.Contains(t, dev.Body.String(), `href="/static/main.css"`)
bare := newHarnessWithStatic(t, t.TempDir()).do("GET", "/", nil, nil)
require.Equal(t, http.StatusOK, bare.Code)
assert.NotContains(t, bare.Body.String(), `rel="stylesheet"`)
}
// The favicon is the chrome's href now, not a literal in the layout: our own
// logo when the build ships one, and ecore's built-in data: URI when it does
// not. The href used to be written into the template unconditionally, so a
// deployment without a static tree asked for a file that was not there once per
// page.
func TestFaviconIsOursWhenShippedAndTheBuiltInOtherwise(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "logo.svg"), []byte("<svg/>"), 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, `<small class="text-muted">`)
}
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", "<b>x</b>"}}, 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(), "<b>x</b>") {
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())
}
}