package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
// postInternalCreate calls handleInternalCreate directly with a JSON body,
// bypassing the router and thus the internalauth guard. The guard's own
// behaviour is sr-ht-ecore's to test, and its agreement with the side that mints
// the header is cmd/dolt-git-hook's; what is left for here is the endpoint's
// business logic, which needs neither the network key nor a loaded
// internal-network list.
func (h *harness) postInternalCreate(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/internal/repos", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.app.handleInternalCreate(rec, req)
return rec
}
// TestInternalCreateRouteIsGuarded is the one assertion the suite above cannot
// make by calling the handler: that /internal/repos is behind the guard at all.
// Nothing here fills the internal network list, so httptest's default TEST-NET-1
// source address is external and the call is refused before its (absent) token
// matters — which is exactly the assertion. An unmounted guard would answer this
// body with a 422 for the unknown owner instead.
func TestInternalCreateRouteIsGuarded(t *testing.T) {
h := newHarness(t)
req := httptest.NewRequest("POST", "/internal/repos",
strings.NewReader(`{"owner":"alice","name":"widgets"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code, rec.Body.String())
assert.Empty(t, h.store.createdCalls, "an unauthenticated call must not reach the handler")
}
func TestInternalCreateProvisions(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
rec := h.postInternalCreate(`{"owner":"~alice","name":"widgets","description":"hi"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create: got %d, want 201 (body: %s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"created":true`) {
t.Fatalf("expected created:true, got %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "/~alice/widgets") {
t.Fatalf("expected companion URL in body, got %s", rec.Body.String())
}
// Row inserted with the resolved owner id and default (PRIVATE) visibility.
if len(h.store.createdCalls) != 1 {
t.Fatalf("expected 1 CreateRepo call, got %d", len(h.store.createdCalls))
}
got := h.store.createdCalls[0]
if got.OwnerID != 7 || got.OwnerName != "alice" || got.Name != "widgets" {
t.Fatalf("unexpected repo: %+v", got)
}
if got.Visibility != core.VisibilityPrivate {
t.Fatalf("default visibility: got %q, want PRIVATE", got.Visibility)
}
// On-disk store initialized at the mapped path — and initialized EMPTY. The
// caller is git.sr.ht's post-update hook, which fires before its user has
// ever pushed to the companion: an "Initialize data repository" commit here
// would make that first push a non-fast-forward and force the user to
// --force their own history in.
if len(h.stores.initEmptyCalls) != 1 || !strings.HasSuffix(h.stores.initEmptyCalls[0], "/~alice/widgets") {
t.Fatalf("expected InitEmptyStore at ~alice/widgets, got %v", h.stores.initEmptyCalls)
}
assert.Empty(t, h.stores.initCalls, "an auto-provisioned companion must carry no initial commit")
}
func TestInternalCreateIdempotent(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
h.store.add(&core.Repo{Name: "widgets", OwnerID: 7, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPrivate})
rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
if rec.Code != http.StatusOK {
t.Fatalf("existing companion: got %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"created":false`) {
t.Fatalf("expected created:false, got %s", rec.Body.String())
}
// Must NOT touch disk when the row already exists.
if len(h.stores.initCalls)+len(h.stores.initEmptyCalls) != 0 {
t.Fatalf("no store init may run for an existing companion, got %v / %v",
h.stores.initCalls, h.stores.initEmptyCalls)
}
}
func TestInternalCreateRollsBackOnStoreFailure(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
h.stores.initErr = errInitBoom
rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("store failure: got %d, want 500", rec.Code)
}
// The metadata row inserted before InitStore must be rolled back.
if len(h.store.deletedRepos) != 1 {
t.Fatalf("expected rollback DeleteRepo, got deletes=%v", h.store.deletedRepos)
}
if _, err := h.store.GetRepoByOwnerAndName(nil, "alice", "widgets"); err != db.ErrNotFound {
t.Fatalf("expected repo removed after rollback, err=%v", err)
}
}
func TestInternalCreateUnknownOwner(t *testing.T) {
h := newHarness(t)
// alice is not registered in fakeUsers -> LookupUser fails.
rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("unknown owner: got %d, want 422 (body: %s)", rec.Code, rec.Body.String())
}
if len(h.store.createdCalls) != 0 {
t.Fatalf("must not create a row for an unresolvable owner")
}
}
func TestInternalCreateRejectsBadName(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
rec := h.postInternalCreate(`{"owner":"alice","name":".."}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("bad name: got %d, want 400", rec.Code)
}
}
// fakeGit is a canned GitDescriber: one description per owner/name key, with
// ok=false for anything unlisted (no git twin / git.sr.ht down).
type fakeGit struct {
byRepo map[string]string
}
func (g *fakeGit) Description(_ context.Context, owner, name string) (string, bool) {
d, ok := g.byRepo[owner+"/"+name]
return d, ok
}
func TestInternalCreateMirrorsGitDescription(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
h.app.cfg.Git = &fakeGit{byRepo: map[string]string{"alice/widgets": "widget factory"}}
rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
require.Len(t, h.store.createdCalls, 1)
assert.Equal(t, "widget factory", h.store.createdCalls[0].Description)
}
func TestInternalCreateSyncsChangedGitDescription(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
h.store.add(&core.Repo{Name: "widgets", Description: "stale", OwnerID: 7, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityUnlisted})
h.app.cfg.Git = &fakeGit{byRepo: map[string]string{"alice/widgets": "fresh"}}
rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
got, err := h.store.GetRepoByOwnerAndName(nil, "alice", "widgets")
require.NoError(t, err)
assert.Equal(t, "fresh", got.Description)
assert.Equal(t, core.VisibilityUnlisted, got.Visibility, "sync must not touch visibility")
}
func TestInternalCreateEmptyGitDescriptionKeepsLocal(t *testing.T) {
h := newHarness(t)
h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"}
h.store.add(&core.Repo{Name: "widgets", Description: "set in dolt", OwnerID: 7, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPrivate})
// Twin exists but carries no description: ("", true) must not clobber.
h.app.cfg.Git = &fakeGit{byRepo: map[string]string{"alice/widgets": ""}}
rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
got, err := h.store.GetRepoByOwnerAndName(nil, "alice", "widgets")
require.NoError(t, err)
assert.Equal(t, "set in dolt", got.Description)
}
var errInitBoom = &boomError{}
type boomError struct{}
func (*boomError) Error() string { return "boom" }