package web
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"github.com/dolthub/dolt/go/libraries/doltcore/creds"
"github.com/go-chi/chi/v5"
"github.com/vaughan0/go-ini"
"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"
)
const selfOrigin = "https://dolt.example"
// testConfig synthesizes a config with the origins the chrome/CSRF checks read.
func testConfig() ini.File {
return ini.File{
"sr.ht": ini.Section{
"environment": "development",
"site-name": "sr.ht",
"owner-name": "admin",
"owner-email": "admin@example.com",
},
"dolt.sr.ht": ini.Section{"origin": selfOrigin},
"meta.sr.ht": ini.Section{"origin": "https://meta.example"},
"git.sr.ht": ini.Section{"origin": "https://git.example"},
"todo.sr.ht": ini.Section{"origin": "https://todo.example"},
"paste.sr.ht": ini.Section{"origin": "https://paste.example"},
}
}
// --- 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
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) {
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
}
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
summary *browse.CommitDiff
closed bool
}
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) {
return s.commits, "", nil
}
func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) {
return s.tables, nil
}
func (s *fakeSession) Rows(_ context.Context, _, _ string, _, _ int) (*browse.RowPage, error) {
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 }
func (b *fakeBrowse) Open(context.Context, string) (BrowseSession, error) {
if b.sess == nil {
return &fakeSession{}, nil
}
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
store *fakeStore
stores *fakeStoreManager
browse *fakeBrowse
users *fakeUsers
}
func newHarness(t *testing.T) *harness {
t.Helper()
store := newFakeStore()
stores := &fakeStoreManager{}
fb := &fakeBrowse{}
users := &fakeUsers{byName: map[string]*core.Caller{}}
cfg := Config{
Conf: testConfig(),
ReposRoot: "/var/lib/dolt",
StaticDir: "",
Stores: stores,
Repos: store,
Browse: fb,
Users: users,
RepoDiskPath: func(owner, name string) string {
return "/var/lib/dolt/~" + owner + "/" + name
},
}
r := chi.NewRouter()
if err := Register(r, cfg); err != nil {
t.Fatalf("Register: %v", err)
}
return &harness{router: r, 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)
}
}
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)
}
}
func TestCreateCSRFRejected(t *testing.T) {
h := newHarness(t)
caller := testCaller(5, "carol")
req := httptest.NewRequest("POST", "/create", strings.NewReader(url.Values{"name": {"x"}, "visibility": {"PUBLIC"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://evil.example")
req = req.WithContext(authn.WithCaller(req.Context(), caller))
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("cross-origin create: got %d, want 403", rec.Code)
}
}
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")
}
}
func TestNavRendersNetworkAndActive(t *testing.T) {
h := newHarness(t)
rec := h.do("GET", "/", testCaller(1, "someone"), nil)
body := rec.Body.String()
// git.sr.ht and todo.sr.ht are network entries; paste is excluded.
if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
t.Fatalf("nav missing network entries; body=%s", body)
}
if strings.Contains(body, "https://paste.example") {
t.Fatalf("nav included excluded paste.sr.ht")
}
// Our own service is active.
if !strings.Contains(body, `nav-item active`) {
t.Fatalf("nav missing active class for self")
}
}
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())
}
}