package main
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
)
// registrar.Add refuses a reserved name or a quota overrun BEFORE it
// resolves a path or touches a store on disk — both checks sit ahead of
// spec.Path resolution — so a bare RepoSpec (no Create, no Clone, no
// Path) reaches them without needing the rest of a running forge (zit,
// events, leases) wired up.
func newTestRegistrar(t *testing.T, quota int) *registrar {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return ®istrar{odb: db, deps: &api.Deps{Repos: api.NewRepos(nil)}, repoQuota: quota}
}
// A repository named after a route the forge keeps for itself is
// refused before anything else is checked — "new" would sit under /new
// itself, "admin" would read as belonging to the operator — and the
// check is case-insensitive.
func TestAddRefusesReservedNames(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddAccount("dan", "pw12345678", false); err != nil {
t.Fatal(err)
}
for _, name := range []string{"new", "fleet", "admin", "New", "ADMIN"} {
_, err := rg.Add(api.RepoSpec{Name: name, Author: "dan"})
if err == nil || !strings.Contains(err.Error(), "keeps for its own routes") {
t.Errorf("Add(%q) = %v, want a reserved-name refusal", name, err)
}
}
// An ordinary name is untouched by the reserved list: it reaches
// the NEXT check (path resolution) rather than being refused here.
if _, err := rg.Add(api.RepoSpec{Name: "ordinary", Author: "dan"}); err == nil ||
strings.Contains(err.Error(), "keeps for its own routes") {
t.Errorf("an ordinary name was caught by the reserved-name check: %v", err)
}
}
// SyncReadGate is what a `zit clone`/`zit pull` against this forge's
// sync endpoint actually consults. It refuses a repository that is
// private OR not mirrorable, and allows an ordinary public, mirrorable
// one — the same invisible-not-forbidden posture either way (both
// answer as a 404 to the caller, so a stranger cannot tell "not
// mirrorable" from "not there").
func TestSyncReadGateRefusesNonMirrorableRepositories(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddRepo("open", t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.AddRepo("closed", t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetRepoMirrorable("closed", false); err != nil {
t.Fatal(err)
}
if err := rg.odb.AddRepo("vault", t.TempDir(), "", oltp.Private); err != nil {
t.Fatal(err)
}
rg.refreshPrivate() // CanRead reads the cached set, not the table directly
req := httptest.NewRequest(http.MethodGet, "/xfer/open", nil)
if !rg.SyncReadGate(req, "open", "") {
t.Error("an ordinary public, mirrorable repository was refused")
}
if rg.SyncReadGate(req, "closed", "") {
t.Error("a repository with mirrorable=false was allowed to be cloned")
}
if rg.SyncReadGate(req, "vault", "") {
t.Error("a private repository was allowed to be cloned anonymously")
}
if rg.SyncReadGate(req, "nope", "") {
t.Error("an unregistered repository answered yes")
}
}
// The quota refuses the (quota+1)th repository an account would
// administer, and NAMES the number in the refusal. A site admin is
// never subject to it, regardless of how many repositories they hold.
func TestAddEnforcesThePerAccountQuota(t *testing.T) {
rg := newTestRegistrar(t, 2)
if err := rg.odb.AddAccount("dan", "pw12345678", false); err != nil {
t.Fatal(err)
}
// The quota counts the deed, not reach: these are repositories dan
// OWNS. An admin grant on somebody else's repository is asserted
// below not to draw on the allowance.
for _, name := range []string{"existing-1", "existing-2"} {
if err := rg.odb.AddRepo(name, t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetOwner(name, "dan"); err != nil {
t.Fatal(err)
}
}
if err := rg.odb.AddRepo("someone-elses", t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetOwner("someone-elses", "grace"); err != nil {
t.Fatal(err)
}
if err := rg.odb.Grant("dan", "someone-elses", oltp.RepoAdmin); err != nil {
t.Fatal(err)
}
_, err := rg.Add(api.RepoSpec{Name: "one-too-many", Author: "dan"})
if err == nil || !strings.Contains(err.Error(), "owns 2 repositories") {
t.Fatalf("quota did not refuse (and name the number) for the third repository: %v", err)
}
if err := rg.odb.AddAccount("boss", "pw12345678", true); err != nil {
t.Fatal(err)
}
for _, name := range []string{"existing-3", "existing-4", "existing-5"} {
if err := rg.odb.AddRepo(name, t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetOwner(name, "boss"); err != nil {
t.Fatal(err)
}
}
if _, err := rg.Add(api.RepoSpec{Name: "boss-repo", Author: "boss"}); err == nil ||
strings.Contains(err.Error(), "administers") {
t.Fatalf("a site admin was refused by the quota: %v", err)
}
}
// Creating a repository is the act of owning one, so the deed is
// recorded at creation rather than inferred later from capabilities,
// and the namespace starts as the creator's own handle: the personal
// case is meant to need no arranging.
//
// This exercises claim directly rather than through Add. Add reaches
// Register, which needs a serving node this helper does not build --
// the same reason the tests above only cover Add's early refusals.
func TestClaimRecordsTheDeedAndThePersonalNamespace(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddRepo("mine", t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
rg.claim(api.RepoSpec{Name: "mine", Author: "dan"})
owner, err := rg.odb.OwnerOf("mine")
if err != nil {
t.Fatal(err)
}
ns, err := rg.odb.NamespaceOf("mine")
if err != nil {
t.Fatal(err)
}
if owner != "dan" || ns != "dan" {
t.Fatalf("owner/namespace = %q/%q, want dan/dan", owner, ns)
}
by, at, err := rg.odb.NamespacePlacement("mine")
if err != nil {
t.Fatal(err)
}
if by != "dan" || at == "" {
t.Fatalf("placement = %q at %q, want dan at a recorded time", by, at)
}
}
// A store an operator serves on behalf of nobody keeps an empty deed
// rather than being attributed to whoever ran the command.
func TestClaimWithNoAuthorLeavesTheRepositoryForgeHeld(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddRepo("served", t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
rg.claim(api.RepoSpec{Name: "served"})
owner, err := rg.odb.OwnerOf("served")
if err != nil {
t.Fatal(err)
}
if owner != "" {
t.Fatalf("owner = %q, want empty -- nobody claimed this store", owner)
}
}
// The third direction of the name-pool rule. oltp refuses an account or
// team named after a repository; this refuses the reverse, and all
// three together are what make a path's first segment unambiguous.
func TestAddRefusesANameANamespaceHolds(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddAccount("ada", "pw12345678", false); err != nil {
t.Fatal(err)
}
if err := rg.odb.AddTeam("hoppers", "Hoppers", "", "ada"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ name, why string }{
{"ada", "an account's name is where its repositories appear"},
{"hoppers", "a team's name is where its repositories appear"},
} {
_, err := rg.Add(api.RepoSpec{Name: tc.name, Author: "ada", Path: t.TempDir(), Create: true})
if err == nil {
t.Errorf("FAIL: a repository took %q, which %s", tc.name, tc.why)
continue
}
if !strings.Contains(err.Error(), "cannot take it") {
t.Errorf("FAIL: %q refused for the wrong reason: %v", tc.name, err)
continue
}
t.Logf("ok: a repository cannot take %q", tc.name)
}
// A name nobody holds is NOT refused here: it passes this check and
// goes on to Register, which this harness cannot build — the same
// limit the other Add tests work within, so they all assert
// refusals rather than successes.
}
// A private repository could not be cloned or synced by ANYONE,
// including the account that owns it: SyncReadGate asked MayMirror of
// every caller, and MayMirror answers no for every private repository
// by construction. "May other people keep a copy" had quietly become
// "may anyone read this at all", so the owner was refused their own
// history (b112b205).
func TestOwnerCanSyncTheirOwnPrivateRepository(t *testing.T) {
rg := newTestRegistrar(t, 25)
const pw = "pw12345678"
if err := rg.odb.AddAccount("owner", pw, false); err != nil {
t.Fatal(err)
}
if err := rg.odb.AddRepo("vault", t.TempDir(), "", oltp.Private); err != nil {
t.Fatal(err)
}
// Created private blanks the categories, so nothing is granted by
// default — standing on a private repository is explicit, which is
// the whole point of it. Give the owner theirs.
if err := rg.odb.SetCaps("owner", "vault", "o"); err != nil {
t.Fatal(err)
}
rg.refreshPrivate()
anon := httptest.NewRequest(http.MethodGet, "/xfer/vault", nil)
if rg.SyncReadGate(anon, "vault", "") {
t.Error("a private repository answered yes to a stranger")
}
owned := httptest.NewRequest(http.MethodGet, "/xfer/vault", nil)
owned.SetBasicAuth("owner", pw)
if !rg.SyncReadGate(owned, "vault", "") {
t.Error("the owner was refused a clone of their own private repository")
}
// A signed-in account with no standing on the repository is still
// a stranger to it: identifying yourself is not the same as being
// admitted, and CanRead is what decides that.
if err := rg.odb.AddAccount("passerby", pw, false); err != nil {
t.Fatal(err)
}
other := httptest.NewRequest(http.MethodGet, "/xfer/vault", nil)
other.SetBasicAuth("passerby", pw)
if rg.SyncReadGate(other, "vault", "") {
t.Error("an unrelated signed-in account was let into a private repository")
}
// Wrong password is not identification. This must fall back to the
// anonymous answer rather than to the authenticated one.
bad := httptest.NewRequest(http.MethodGet, "/xfer/vault", nil)
bad.SetBasicAuth("owner", "not-the-password")
if rg.SyncReadGate(bad, "vault", "") {
t.Error("a bad password was treated as the owner")
}
// THE CASE THIS GATE EXISTED TO REFUSE AND SHOULD NOT HAVE.
//
// A sync client does not send HTTP Basic: fossil and zit
// authenticate with the login card, inside the request body. Every
// case above uses Basic, which a browser and curl send and no sync
// client does — so the gate admitted the shapes nobody syncs with
// and refused every real one. `zit clone` of a private repository
// answered 404 for its owner exactly as it did for a stranger
// (b112b205). xfer verifies the card against the repository's
// shared secret and passes the name it proved.
card := httptest.NewRequest(http.MethodGet, "/xfer/vault", nil)
if !rg.SyncReadGate(card, "vault", "owner") {
t.Error("a verified sync login from the owner was refused: " +
"a private repository stays unclonable by the people it admits")
}
// The login is identity, not admission. Someone the repository does
// not admit is still refused, however well they proved who they are.
if rg.SyncReadGate(card, "vault", "passerby") {
t.Error("a verified login from an account with no standing was let in")
}
}
// mirrorable=false still refuses a stranger on a public repository —
// that flag's actual job, and the case the owner fix must not widen.
func TestMirrorableStillRefusesStrangers(t *testing.T) {
rg := newTestRegistrar(t, 25)
const pw = "pw12345678"
if err := rg.odb.AddAccount("keeper", pw, false); err != nil {
t.Fatal(err)
}
if err := rg.odb.AddRepo("closed", t.TempDir(), "", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetRepoMirrorable("closed", false); err != nil {
t.Fatal(err)
}
rg.refreshPrivate()
anon := httptest.NewRequest(http.MethodGet, "/xfer/closed", nil)
if rg.SyncReadGate(anon, "closed", "") {
t.Error("mirroring is off, yet a stranger was allowed to clone")
}
}
// registrar.Update writes only the columns the patch names.
//
// This is the same property the api-level guard asserts, checked one
// layer down against a real database: the seam can carry a tidy patch
// and still clobber if the implementation writes every column anyway.
// It is also where the pair used to be indivisible -- homepage and
// topics were one UPDATE, so naming either one rewrote both.
//
// The failure being guarded against is a lost update: two admins saving
// different panels in the same moment, where the later write reverts
// the earlier one with no error and no trace (G-68).
func TestUpdateWritesOnlyTheNamedColumns(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddRepo("proj", t.TempDir(), "the original description", oltp.Public); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetRepoHomepage("proj", "https://original.example"); err != nil {
t.Fatal(err)
}
if err := rg.odb.SetRepoTopics("proj", "forge zit"); err != nil {
t.Fatal(err)
}
// One panel's worth of change: the homepage, and nothing else.
page := "https://changed.example"
if err := rg.Update("proj", api.RepoEdit{Homepage: &page}, "dmestas"); err != nil {
t.Fatal(err)
}
r, ok, err := rg.odb.RepoByName("proj")
if err != nil || !ok {
t.Fatalf("RepoByName: ok=%v err=%v", ok, err)
}
if r.Homepage != page {
t.Errorf("homepage = %q, want %q: the named column was not written", r.Homepage, page)
}
if r.Description != "the original description" {
t.Errorf("description = %q: a homepage-only update rewrote the description, so a "+
"concurrent edit to it is silently reverted", r.Description)
}
if r.Topics != "forge zit" {
t.Errorf("topics = %q, want %q: homepage and topics are still written as one "+
"column pair, so naming either rewrites both", r.Topics, "forge zit")
}
if r.Visibility != oltp.Public {
t.Errorf("visibility = %q: a homepage-only update rewrote visibility -- this is how "+
"a repository nobody asked to make private becomes private", r.Visibility)
}
if !r.Mirrorable {
t.Error("a homepage-only update turned the mirrorable flag off, which stops the " +
"repository syncing and reports nothing")
}
}
// A settings change through the registrar leaves a durable, attributed
// record: who, which field, from what, when.
//
// The incident this exists for went unattributable for an hour. The
// only record was the settings page's 24-hour event tail, where every
// row reads "settings changed" -- no field, no previous value, no
// absolute timestamp -- so a repository that had become private could
// not be traced to a request, and the before-value had to come from the
// nightly backup (824a61b3).
func TestUpdateLeavesAnAttributedRecord(t *testing.T) {
rg := newTestRegistrar(t, 25)
if err := rg.odb.AddRepo("proj", t.TempDir(), "before", oltp.Public); err != nil {
t.Fatal(err)
}
after := "after"
if err := rg.Update("proj", api.RepoEdit{Description: &after}, "jva"); err != nil {
t.Fatal(err)
}
log, err := rg.odb.SettingsLog("proj", 10)
if err != nil {
t.Fatal(err)
}
if len(log) != 1 {
t.Fatalf("want one settings-log row, got %d: %+v", len(log), log)
}
got := log[0]
if got.Actor != "jva" {
t.Errorf("actor = %q, want jva: the record cannot say who made the change", got.Actor)
}
if got.Field != "description" {
t.Errorf("field = %q, want description: a record that only says "+
"\"settings changed\" is the one that failed us", got.Field)
}
if got.Was != "before" || got.Now != "after" {
t.Errorf("was/now = %q/%q, want before/after: without the previous value a "+
"restore needs the nightly backup", got.Was, got.Now)
}
if got.At == "" {
t.Error("the row has no absolute timestamp, so it cannot be lined up with a log " +
"or a backup -- the existing trail shows only \"13 minutes ago\"")
}
}