From ce06498a3dd0d801f334f0c60345ab2174a618fb Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 13:44:35 +0300 Subject: [PATCH] feat: auto-provision companion Dolt DBs from git.sr.ht pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a service-to-service path so pushing a git.sr.ht repo creates a matching Dolt database at ~owner/name, ready before the user's first `dolt push`. - web: POST /internal/repos, guarded by internal-IP + network-key `Internal` auth (not the browser cookie/CSRF). Resolves/mirrors the owner via auth.LookupUser, then CreateRepo + InitStore, rolling back the row if the store init fails. Idempotent: an existing companion returns 200, a fresh one 201 — safe to call on every push. - cmd/dolt-git-hook: the git.sr.ht post-update-script. Delegates every hook stage to the stock /usr/bin/git.sr.ht-update-hook unchanged (argv[0], stdin, env, exit code preserved; fail-closed if the delegate is missing), then on post-update POSTs the companion create and prints a one-time clone notice. Best-effort: never fails a push, degrades to a warning on any misconfig. Tests cover the endpoint (provision/idempotent/rollback/bad-input) and the hook (signed request round-trips through the guard's decryption, notice only on 201). --- cmd/dolt-git-hook/main.go | 234 +++++++++++++++++++++++++++++++++ cmd/dolt-git-hook/main_test.go | 142 ++++++++++++++++++++ web/handlers_internal.go | 204 ++++++++++++++++++++++++++++ web/handlers_internal_test.go | 118 +++++++++++++++++ web/router.go | 5 + 5 files changed, 703 insertions(+) create mode 100644 cmd/dolt-git-hook/main.go create mode 100644 cmd/dolt-git-hook/main_test.go create mode 100644 web/handlers_internal.go create mode 100644 web/handlers_internal_test.go diff --git a/cmd/dolt-git-hook/main.go b/cmd/dolt-git-hook/main.go new file mode 100644 index 0000000000000000000000000000000000000000..cd8f55bd42c55404032d02fbbbc6bfbbaaae1f9a --- /dev/null +++ b/cmd/dolt-git-hook/main.go @@ -0,0 +1,234 @@ +// Command dolt-git-hook is git.sr.ht's post-update-script, replacing the stock +// /usr/bin/git.sr.ht-update-hook in [git.sr.ht]post-update-script. git.sr.ht +// symlinks the configured script as all four repo hooks (pre-receive, update, +// post-update, post-receive); this binary is that script. +// +// It does two things: +// +// 1. Delegates every invocation to the stock hook, unchanged — same argv[0] +// (so the stock binary's os.Args[0] dispatch still fires), same stdin, env, +// working directory and exit code. Build submission, webhook delivery, ACL +// enforcement and the stock autocreate notice all keep working. +// +// 2. On the post-update stage only, provisions a companion Dolt database at +// ~owner/name via dolt.sr.ht's internal create endpoint, so a matching Dolt +// DB exists before the user's first `dolt push`, and prints a one-time +// terminal notice when it is first created. This step is strictly +// best-effort: it never changes the delegate's exit code and never fails a +// push (post-update runs after refs are already updated). +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "sourcecraft.dev/bigbes/sr-ht-core/config" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" +) + +// defaultDelegate is the stock git.sr.ht hook this wrapper wraps. The apk still +// installs it here; only the [git.sr.ht]post-update-script symlink target moves +// to this binary. Overridable via env for testing. +const defaultDelegate = "/usr/bin/git.sr.ht-update-hook" + +// provisionTimeout bounds the internal create call so a slow or down dolt.sr.ht +// never adds more than this to a push. +const provisionTimeout = 5 * time.Second + +func main() { + code := runDelegate() + + // filepath.Base("hooks/post-update") == "post-update". + if filepath.Base(os.Args[0]) == "post-update" { + provisionDolt() + } + + os.Exit(code) +} + +// runDelegate execs the stock hook with this process's argv, stdio, env and +// working directory, and returns its exit code. If the delegate cannot be +// started at all (e.g. missing binary) it fails closed with a non-zero code: +// the stock hook is where ACL enforcement lives, so a push must not proceed +// without it. +func runDelegate() int { + delegate := os.Getenv("DOLT_GIT_HOOK_DELEGATE") + if delegate == "" { + delegate = defaultDelegate + } + + cmd := exec.Command(delegate) + cmd.Args = os.Args // preserve argv[0] = "hooks/" for the stock dispatch + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + err := cmd.Run() + if err == nil { + return 0 + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode() + } + fmt.Fprintf(os.Stderr, "dolt-git-hook: cannot run %s: %v\n", delegate, err) + return 1 +} + +// pushContext is the subset of git.sr.ht's SRHT_PUSH_CTX we need: the repo's +// owner, name and visibility. git.sr.ht-shell sets this env var before exec'ing +// git-receive-pack, so it is inherited by every hook. +type pushContext struct { + Repo struct { + Name string `json:"name"` + OwnerName string `json:"owner_name"` + Visibility string `json:"visibility"` + } `json:"repo"` +} + +// internalAuth mirrors core-go's client.InternalAuth wire shape. crypto.Encrypt +// seals it with the shared [sr.ht]network-key; dolt.sr.ht's internalAuthGuard +// decrypts and trusts it. +type internalAuth struct { + Name string `json:"name,omitempty"` + ClientID string `json:"client_id"` + NodeID string `json:"node_id"` +} + +// provisionDolt asks dolt.sr.ht to create the companion database for the pushed +// repo. Every failure mode is swallowed (logged to stderr at most): this is a +// convenience, not part of the push contract. +func provisionDolt() { + // A bug here must never escape into the push; recover defensively (config + // loading log.Fatalf's are handled by only running post-update, where the + // exit code is already irrelevant to push success). + defer func() { _ = recover() }() + + raw := os.Getenv("SRHT_PUSH_CTX") + if raw == "" { + return // push not routed through git.sr.ht-shell; nothing to do + } + var pc pushContext + if err := json.Unmarshal([]byte(raw), &pc); err != nil { + return + } + if pc.Repo.Name == "" || pc.Repo.OwnerName == "" { + return + } + + conf := config.LoadConfig() + + // crypto.InitCrypto log.Fatalf's (os.Exit) on a missing key, which recover + // cannot catch — check the keys ourselves first so a misconfigured instance + // degrades to a skipped companion, never a hard-exiting hook. + if _, ok := conf.Get("sr.ht", "network-key"); !ok { + fmt.Fprintln(os.Stderr, "dolt-git-hook: [sr.ht]network-key not set; skipping companion provisioning") + return + } + if _, ok := conf.Get("webhooks", "private-key"); !ok { + fmt.Fprintln(os.Stderr, "dolt-git-hook: [webhooks]private-key not set; skipping companion provisioning") + return + } + + origin, ok := conf.Get("dolt.sr.ht", "internal-origin") + if !ok || origin == "" { + fmt.Fprintln(os.Stderr, "dolt-git-hook: [dolt.sr.ht]internal-origin not set; skipping companion provisioning") + return + } + + crypto.InitCrypto(conf) + + createCompanion(os.Stderr, origin, pc) +} + +// createCompanion POSTs the internal create request for pc to origin, signing it +// with the shared network-key (crypto must already be initialized), and reports +// progress to out: the one-time notice on a fresh 201, silence on an existing +// 200, a warning on anything else. It is separated from provisionDolt so it can +// be integration-tested against an httptest server without config-file loading. +func createCompanion(out io.Writer, origin string, pc pushContext) { + reqBody := map[string]string{ + "owner": pc.Repo.OwnerName, + "name": pc.Repo.Name, + } + if v := normalizeVisibility(pc.Repo.Visibility); v != "" { + reqBody["visibility"] = v + } + body, _ := json.Marshal(reqBody) + + authBlob, _ := json.Marshal(internalAuth{ + Name: pc.Repo.OwnerName, + ClientID: "git.sr.ht", + NodeID: "dolt-git-hook", + }) + + req, err := http.NewRequest("POST", + strings.TrimRight(origin, "/")+"/internal/repos", bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(authBlob))) + + client := &http.Client{Timeout: provisionTimeout} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(out, "dolt-git-hook: dolt.sr.ht unreachable: %v\n", err) + return + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusCreated: + var r struct { + URL string `json:"url"` + } + _ = json.NewDecoder(resp.Body).Decode(&r) + printNotice(out, pc.Repo.OwnerName, pc.Repo.Name, r.URL) + case http.StatusOK: + // Companion already existed; stay quiet so only the first push announces. + default: + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + fmt.Fprintf(out, "dolt-git-hook: companion provisioning failed (%d): %s\n", + resp.StatusCode, strings.TrimSpace(string(msg))) + } +} + +// normalizeVisibility maps a git.sr.ht visibility to the dolt.sr.ht enum, +// returning "" (let the endpoint default to PRIVATE) for anything unrecognized. +func normalizeVisibility(v string) string { + switch strings.ToUpper(strings.TrimSpace(v)) { + case "PUBLIC": + return "PUBLIC" + case "UNLISTED": + return "UNLISTED" + case "PRIVATE": + return "PRIVATE" + default: + return "" + } +} + +// printNotice writes the one-time companion-created notice to out (os.Stderr in +// production), which git relays to the pushing client's terminal — the same +// stream and stage git.sr.ht uses for its own autocreate notice. +func printNotice(out io.Writer, owner, name, url string) { + if url == "" { + url = fmt.Sprintf("(~%s/%s)", owner, name) + } + fmt.Fprintf(out, "\n\t\033[93mNOTICE\033[0m\n"+ + "\tA Dolt database companion has been created for ~%s/%s:\n\n"+ + "\t dolt clone %s\n"+ + "\t web: %s\n\n", + owner, name, url, url) +} diff --git a/cmd/dolt-git-hook/main_test.go b/cmd/dolt-git-hook/main_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d805df39cc5e24dfaa8e64d65df2aabf5ad4b9db --- /dev/null +++ b/cmd/dolt-git-hook/main_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/fernet/fernet-go" + "github.com/vaughan0/go-ini" + + "sourcecraft.dev/bigbes/sr-ht-core/crypto" +) + +// initTestCrypto installs a random network key + webhooks seed into the shared +// crypto globals, mirroring the sr-ht-core test pattern, so createCompanion's +// crypto.Encrypt and the test server's DecryptWithExpiration share a keyset. +func initTestCrypto(t *testing.T) { + t.Helper() + var fk fernet.Key + if err := fk.Generate(); err != nil { + t.Fatalf("fernet generate: %v", err) + } + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = byte(i + 1) + } + crypto.InitCrypto(ini.File{ + "sr.ht": ini.Section{"network-key": fk.Encode()}, + "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)}, + }) +} + +func samplePush() pushContext { + var pc pushContext + pc.Repo.Name = "widgets" + pc.Repo.OwnerName = "alice" + pc.Repo.Visibility = "private" + return pc +} + +// decodeInternalAuth verifies an incoming "Internal " header the same way +// dolt.sr.ht's internalAuthGuard does and returns the decoded claims. +func decodeInternalAuth(t *testing.T, r *http.Request) internalAuth { + t.Helper() + parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "internal") { + t.Fatalf("missing/invalid Internal auth header: %q", r.Header.Get("Authorization")) + } + payload := crypto.DecryptWithExpiration([]byte(parts[1]), 30*time.Second) + if payload == nil { + t.Fatalf("auth token did not decrypt (wrong key or expired)") + } + var ia internalAuth + if err := json.Unmarshal(payload, &ia); err != nil { + t.Fatalf("auth payload not JSON: %v", err) + } + return ia +} + +func TestCreateCompanionSignsAndAnnouncesOnCreate(t *testing.T) { + initTestCrypto(t) + + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/repos" || r.Method != http.MethodPost { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + ia := decodeInternalAuth(t, r) + if ia.ClientID != "git.sr.ht" || ia.NodeID != "dolt-git-hook" { + t.Errorf("unexpected auth claims: %+v", ia) + } + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"url":"https://dolt.example/~alice/widgets","created":true}`)) + })) + defer srv.Close() + + var out bytes.Buffer + createCompanion(&out, srv.URL, samplePush()) + + if gotBody["owner"] != "alice" || gotBody["name"] != "widgets" { + t.Fatalf("unexpected request body: %+v", gotBody) + } + if gotBody["visibility"] != "PRIVATE" { + t.Fatalf("visibility not normalized to PRIVATE: %q", gotBody["visibility"]) + } + if !strings.Contains(out.String(), "NOTICE") || + !strings.Contains(out.String(), "dolt clone https://dolt.example/~alice/widgets") { + t.Fatalf("expected clone notice, got: %q", out.String()) + } +} + +func TestCreateCompanionQuietWhenExists(t *testing.T) { + initTestCrypto(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decodeInternalAuth(t, r) // still must be authenticated + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"url":"https://dolt.example/~alice/widgets","created":false}`)) + })) + defer srv.Close() + + var out bytes.Buffer + createCompanion(&out, srv.URL, samplePush()) + if out.Len() != 0 { + t.Fatalf("expected no output for existing companion, got: %q", out.String()) + } +} + +func TestCreateCompanionWarnsOnError(t *testing.T) { + initTestCrypto(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + var out bytes.Buffer + createCompanion(&out, srv.URL, samplePush()) + if !strings.Contains(out.String(), "provisioning failed (500)") { + t.Fatalf("expected failure warning, got: %q", out.String()) + } +} + +func TestNormalizeVisibility(t *testing.T) { + cases := map[string]string{ + "private": "PRIVATE", "PUBLIC": "PUBLIC", "Unlisted": "UNLISTED", + "": "", "bogus": "", + } + for in, want := range cases { + if got := normalizeVisibility(in); got != want { + t.Errorf("normalizeVisibility(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/web/handlers_internal.go b/web/handlers_internal.go new file mode 100644 index 0000000000000000000000000000000000000000..686beee955f90f0bf8d9838e43025ada108e9942 --- /dev/null +++ b/web/handlers_internal.go @@ -0,0 +1,204 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + "sourcecraft.dev/bigbes/sr-ht-core/config" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" + + "sourcecraft.dev/bigbes/sr-ht-dolt/core" + "sourcecraft.dev/bigbes/sr-ht-dolt/db" +) + +// This file implements the service-to-service create endpoint that lets other +// SourceHut services provision a companion Dolt database. Its first (and only) +// caller is git.sr.ht's post-update hook, which POSTs here whenever a git repo +// is pushed so a matching Dolt DB exists at ~owner/name before the user's first +// `dolt push`. It is NOT a browser route: it carries no CSRF token and no +// unified-login cookie, and is guarded by internalAuthGuard instead of the +// cookie auth the rest of web/ uses. + +// internalCreateRequest is the JSON body POSTed to /internal/repos. Owner is a +// SourceHut username (with or without the leading "~"); Name is the database +// name. Visibility is optional and defaults to PRIVATE — the safe default for +// an auto-provisioned companion the user has not explicitly published. +type internalCreateRequest struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` +} + +// internalCreateResponse is returned on success. Created distinguishes a +// freshly provisioned database (201) from one that already existed (200), so +// the caller can decide whether to announce the companion to the user. +type internalCreateResponse struct { + URL string `json:"url"` + Created bool `json:"created"` +} + +// internalAuthGuard authenticates a service-to-service request the same way +// core-go's auth.internalAuth does — the source IP must fall inside +// [sr.ht]internal-ipnet AND the Authorization header must be a valid, +// unexpired "Internal " minted with the shared [sr.ht]network-key. +// The network-key check is the real guard (only internal services hold it); the +// IP check is defense-in-depth against the endpoint being reachable through the +// public Traefik route. We reimplement rather than reuse auth.Middleware because +// that middleware 401s any request without a cookie/bearer and pulls in the +// full user-resolution path we do not need here — the owner comes from the body. +func internalAuthGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + if ip == nil || !config.IsInternalIP(ip) { + http.Error(w, "internal auth: source IP not permitted", http.StatusUnauthorized) + return + } + + parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "internal") { + http.Error(w, "internal auth: Internal authorization required", http.StatusUnauthorized) + return + } + payload := crypto.DecryptWithExpiration([]byte(parts[1]), 30*time.Second) + if payload == nil { + http.Error(w, "internal auth: invalid or expired token", http.StatusForbidden) + return + } + var ia struct { + ClientID string `json:"client_id"` + NodeID string `json:"node_id"` + } + if err := json.Unmarshal(payload, &ia); err != nil || ia.ClientID == "" || ia.NodeID == "" { + http.Error(w, "internal auth: malformed token", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// handleInternalCreate provisions a Dolt database for owner/name. It mirrors +// handleCreate's insert-row-then-init-store ordering (so metadata and disk +// never diverge) but is idempotent: a repeated call for an existing companion +// returns 200 instead of an error, because the caller fires on every push and +// must not fail once the DB already exists. +func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) { + var req internalCreateRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + http.Error(w, "malformed JSON body", http.StatusBadRequest) + return + } + req.Owner = strings.TrimPrefix(strings.TrimSpace(req.Owner), "~") + req.Name = strings.TrimSpace(req.Name) + + if req.Owner == "" { + http.Error(w, "owner is required", http.StatusBadRequest) + return + } + if err := core.ValidateName(req.Name); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + visibility := core.VisibilityPrivate + if req.Visibility != "" { + v, ok := parseVisibility(req.Visibility) + if !ok { + http.Error(w, "invalid visibility", http.StatusBadRequest) + return + } + visibility = v + } + + ctx := r.Context() + + // Resolve (and, on first sight, mirror) the owner's account so we have a + // local UserID to own the row. A permanent miss (no such meta user) is a + // client error, not a server fault. + caller, err := a.cfg.Users.LookupUser(ctx, req.Owner) + if err != nil { + http.Error(w, fmt.Sprintf("resolve owner %q: %v", req.Owner, err), http.StatusUnprocessableEntity) + return + } + + url := a.repoURL(caller.Username, req.Name) + diskPath := a.cfg.RepoDiskPath(caller.Username, req.Name) + repo := &core.Repo{ + Name: req.Name, + Description: req.Description, + OwnerID: caller.UserID, + OwnerName: caller.Username, + Path: diskPath, + Visibility: visibility, + } + + // Insert the metadata row first: a name collision (ErrNameTaken) means the + // companion already exists, which for this idempotent endpoint is success, + // not an error — return 200 without touching disk. + created, err := a.cfg.Repos.CreateRepo(ctx, repo) + if err != nil { + if errors.Is(err, db.ErrNameTaken) { + writeJSON(w, http.StatusOK, internalCreateResponse{URL: url, Created: false}) + return + } + http.Error(w, "create database", http.StatusInternalServerError) + return + } + + // The initial empty commit's author is cosmetic (real pushes overwrite + // history); mirror handleCreate and author it as the instance owner, + // falling back to the database owner. + authorName, authorEmail := config.GetOwner(a.cfg.Conf) + if authorName == "" { + authorName = caller.Username + } + if authorEmail == "" { + authorEmail = caller.Username + "@" + hostOf(config.GetOrigin(a.cfg.Conf, serviceName, true)) + } + if err := a.cfg.Stores.InitStore(ctx, diskPath, authorName, authorEmail); err != nil { + // InitStore self-cleans its directory; undo the metadata row too so a + // failed provision leaves nothing behind and a retry can start clean. + _ = a.cfg.Repos.DeleteRepo(ctx, created.ID) + http.Error(w, "initialize database store", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, internalCreateResponse{URL: url, Created: true}) +} + +// repoURL builds the external web URL for a database, e.g. +// https://dolt.srht.bigb.es/~owner/name. +func (a *app) repoURL(owner, name string) string { + origin := strings.TrimRight(config.GetOrigin(a.cfg.Conf, serviceName, true), "/") + return fmt.Sprintf("%s/~%s/%s", origin, owner, name) +} + +// hostOf returns the host authority of a URL, or the input unchanged if it does +// not parse as one (used only to synthesize a cosmetic commit-author email). +func hostOf(origin string) string { + if i := strings.Index(origin, "://"); i >= 0 { + origin = origin[i+3:] + } + if i := strings.IndexByte(origin, '/'); i >= 0 { + origin = origin[:i] + } + if origin == "" { + return "localhost" + } + return origin +} + +// writeJSON writes v as a JSON response with the given status. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/web/handlers_internal_test.go b/web/handlers_internal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f5eb32b5bd11363abd67f997ec5c97d1c3f606bb --- /dev/null +++ b/web/handlers_internal_test.go @@ -0,0 +1,118 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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 internalAuthGuard, which is exercised +// separately). The auth guard needs the process-global crypto/config state a +// unit test does not set up; the business logic under test here does not. +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 +} + +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. + if len(h.stores.initCalls) != 1 || !strings.HasSuffix(h.stores.initCalls[0], "/~alice/widgets") { + t.Fatalf("expected InitStore at ~alice/widgets, got %v", h.stores.initCalls) + } +} + +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) != 0 { + t.Fatalf("InitStore must not run for an existing companion, got %v", h.stores.initCalls) + } +} + +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) + } +} + +var errInitBoom = &boomError{} + +type boomError struct{} + +func (*boomError) Error() string { return "boom" } diff --git a/web/router.go b/web/router.go index 3e7833148d5d928e3414ba764670329b7c16285a..c955303fe27d7b7bba18fbe12cc5459dd3a8b5aa 100644 --- a/web/router.go +++ b/web/router.go @@ -68,6 +68,11 @@ func (a *app) mount(r chi.Router) { r.Get("/create", a.handleCreateForm) r.Post("/create", a.handleCreate) + // Service-to-service companion provisioning (git.sr.ht post-update hook). + // Guarded by internal-network + network-key auth, not the cookie/CSRF the + // browser routes use. + r.With(internalAuthGuard).Post("/internal/repos", a.handleInternalCreate) + r.Get("/settings/keys", a.handleKeys) r.Post("/settings/keys", a.handleKeysPost)