//go:build integration // Integration test for the full remotesapi stack: real Postgres (Docker), the // real dolt CLI (v2.1.10) driving clone/push over our auth interceptors, and the // CredentialsService.WhoAmI RPC. It exercises every auth path end to end: // // (1) anonymous clone of a PUBLIC db succeeds; // (2) anonymous push is denied; // (3) Basic auth (--user + DOLT_REMOTE_PASSWORD, forged PAT) push to own repo; // (4) PRIVATE anonymous clone fails "not found"; // (5) keypair (Bearer EdDSA JWT) clone of a PRIVATE own db succeeds after the // key is registered (dolt creds flow, no --user); // (6) WhoAmI answers with the account identity for a keypair JWT; // (7) an ACL RO grantee can clone but not push (private repo stays "found"). // // Run with: // // CGO_CPPFLAGS=-I/opt/homebrew/opt/icu4c@78/include \ // CGO_LDFLAGS=-L/opt/homebrew/opt/icu4c@78/lib \ // go test -tags integration -mod=readonly ./remoteapi/ -run TestRemoteAPIIntegration -v // // It skips cleanly when the dolt CLI or Docker (and DOLTSRHT_TEST_PG) are absent. package remoteapi import ( "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/hex" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "testing" "time" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/crypto" remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1" "github.com/dolthub/dolt/go/libraries/doltcore/creds" "github.com/fernet/fernet-go" _ "github.com/lib/pq" "github.com/sirupsen/logrus" "github.com/vaughan0/go-ini" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/core" "sourcecraft.dev/bigbes/sr-ht-dolt/db" "sourcecraft.dev/bigbes/sr-ht-dolt/storage" ) const doltBin = "/opt/homebrew/bin/dolt" // stubMeta is an in-memory MetaBackend: it mirrors a fixed user table and never // reports a token revoked. It lets the full auth stack run without a live // meta.sr.ht or the internal-network trust it needs. type stubMeta struct { users map[string]auth.AuthContext } func (s *stubMeta) LookupUser(_ context.Context, username string, out *auth.AuthContext) error { u, ok := s.users[strings.ToLower(strings.TrimPrefix(username, "~"))] if !ok { return fmt.Errorf("stubMeta: unknown user %q", username) } *out = u return nil } func (s *stubMeta) IsRevoked(_ context.Context, _ string, _ [64]byte, _ string) (bool, error) { return false, nil } func TestRemoteAPIIntegration(t *testing.T) { if _, err := os.Stat(doltBin); err != nil { t.Skipf("dolt CLI not found at %s (%v); skipping integration test", doltBin, err) } dsn, stopPG := startPostgres(t) defer stopPG() pool, err := sql.Open("postgres", dsn) if err != nil { t.Fatalf("open pool: %v", err) } defer pool.Close() waitPG(t, pool) applySchema(t, pool) // Shared crypto keyset (fernet network key + ed25519 webhooks seed) so forged // PATs validate; the same ini is threaded into the server as its config. conf := synthConf(t) crypto.InitCrypto(conf) // Install the in-memory meta backend for the duration of the test. restore := authn.SetMetaBackend(&stubMeta{users: map[string]auth.AuthContext{ "alice": {UserID: 1, Username: "alice", Email: "alice@example.test", UserType: auth.USER_TYPE_USER}, "bob": {UserID: 2, Username: "bob", Email: "bob@example.test", UserType: auth.USER_TYPE_USER}, }}) defer restore() ctx := context.Background() store := db.NewStore(pool) insertUser(t, pool, 1, "alice", core.UserTypeUser) insertUser(t, pool, 2, "bob", core.UserTypeUser) reposRoot := filepath.Join(t.TempDir(), "repos") if err := os.MkdirAll(reposRoot, 0o755); err != nil { t.Fatal(err) } pubRepo := mkRepo(t, ctx, store, reposRoot, 1, "alice", "pubdb", core.VisibilityPublic) privRepo := mkRepo(t, ctx, store, reposRoot, 1, "alice", "privdb", core.VisibilityPrivate) // A separate private repo that bob will hold an RO ACL on (scenario 7). roRepo := mkRepo(t, ctx, store, reposRoot, 1, "alice", "rodb", core.VisibilityPrivate) if err := store.UpsertACL(ctx, roRepo.ID, 2, core.AccessRO); err != nil { t.Fatalf("grant bob RO on rodb: %v", err) } _ = pubRepo _ = privRepo // Start both servers on ephemeral ports. HttpHost is the remotesapi listen // addr so chunk URLs carry the right host:port AND the derived JWT audience // (the bare host) matches what the CLI sends. addr := freeAddr(t) credAddr := freeAddr(t) // dolt's remotesrv logs every chunk transfer at info; this test moves a // database, so quiet it down to what a failure needs. It is a logrus entry // because that is the only shape remotesrv's API accepts — see // Config.DoltLogger. doltLogger := logrus.NewEntry(logrus.New()) doltLogger.Logger.SetLevel(logrus.ErrorLevel) cfg := Config{ Conf: conf, DB: pool, ReposRoot: reposRoot, ListenAddr: addr, CredsListenAddr: credAddr, HttpHost: addr, DoltLogger: doltLogger, } srv, err := New(cfg) if err != nil { t.Fatalf("New: %v", err) } go func() { if err := srv.Serve(); err != nil { logger.Errorf("remotesapi serve: %v", err) } }() defer srv.GracefulStop() csrv, err := NewCredServer(cfg) if err != nil { t.Fatalf("NewCredServer: %v", err) } go func() { if err := csrv.Serve(); err != nil { logger.Errorf("credentials serve: %v", err) } }() defer csrv.GracefulStop() waitTCP(t, addr) waitTCP(t, credAddr) // Scratch HOME so the developer's real dolt config is untouched. home := filepath.Join(t.TempDir(), "home") if err := os.MkdirAll(home, 0o755); err != nil { t.Fatal(err) } baseEnv := append(os.Environ(), "HOME="+home) runDolt(t, baseEnv, home, "config", "--global", "--add", "user.email", "alice@example.test") runDolt(t, baseEnv, home, "config", "--global", "--add", "user.name", "alice") pubURL := fmt.Sprintf("http://%s/~alice/pubdb", addr) privURL := fmt.Sprintf("http://%s/~alice/privdb", addr) roURL := fmt.Sprintf("http://%s/~alice/rodb", addr) pat := forgePAT("alice", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour)) // (1) Anonymous clone of PUBLIC succeeds. t.Run("anonymous_public_clone", func(t *testing.T) { dir := t.TempDir() runDolt(t, baseEnv, dir, "clone", pubURL) if _, err := os.Stat(filepath.Join(dir, "pubdb", ".dolt")); err != nil { t.Fatalf("clone did not produce a working dir: %v", err) } }) // (2) Anonymous push is denied. t.Run("anonymous_push_denied", func(t *testing.T) { dir := t.TempDir() runDolt(t, baseEnv, dir, "clone", pubURL) work := filepath.Join(dir, "pubdb") runDolt(t, baseEnv, work, "sql", "-q", "create table t(i int primary key); insert into t values (1);") runDolt(t, baseEnv, work, "commit", "-Am", "anon change") out, err := tryDolt(baseEnv, work, "push", "origin", "main") if err == nil { t.Fatalf("anonymous push unexpectedly succeeded:\n%s", out) } t.Logf("anonymous push correctly denied:\n%s", out) }) // (3) Basic auth (forged PAT) push to own repo succeeds. t.Run("basic_auth_push", func(t *testing.T) { dir := t.TempDir() env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+pat) runDolt(t, env, dir, "clone", "--user", "alice", pubURL) work := filepath.Join(dir, "pubdb") runDolt(t, env, work, "sql", "-q", "create table basic(i int primary key); insert into basic values (7);") runDolt(t, env, work, "commit", "-Am", "basic push") runDolt(t, env, work, "push", "--user", "alice", "origin", "main") // Re-clone anonymously and verify the pushed row landed. dir2 := t.TempDir() runDolt(t, baseEnv, dir2, "clone", pubURL) out := runDolt(t, baseEnv, filepath.Join(dir2, "pubdb"), "sql", "-q", "select i from basic", "-r", "csv") if !strings.Contains(out, "7") { t.Fatalf("pushed row not present after re-clone; got:\n%s", out) } }) // (4) PRIVATE anonymous clone fails "not found". t.Run("private_anonymous_notfound", func(t *testing.T) { dir := t.TempDir() out, err := tryDolt(baseEnv, dir, "clone", privURL) if err == nil { t.Fatalf("anonymous clone of PRIVATE unexpectedly succeeded:\n%s", out) } if !strings.Contains(strings.ToLower(out), "not found") { t.Fatalf("expected a not-found style failure, got:\n%s", out) } t.Logf("private anonymous clone correctly not found:\n%s", out) }) // (5) Keypair (Bearer JWT) clone of PRIVATE own db succeeds. Mint a keypair, // register it for alice, then clone with no --user so the CLI uses the JWK. t.Run("keypair_private_clone", func(t *testing.T) { dc, err := creds.GenerateCredentials() if err != nil { t.Fatalf("generate creds: %v", err) } kid := dc.KeyIDBase32Str() if _, err := store.InsertKey(ctx, 1, kid, dc.PubKey, "it-key"); err != nil { t.Fatalf("register dolt key: %v", err) } writeJWK(t, home, dc) runDolt(t, baseEnv, home, "config", "--global", "--add", "user.creds", kid) dir := t.TempDir() runDolt(t, baseEnv, dir, "clone", privURL) if _, err := os.Stat(filepath.Join(dir, "privdb", ".dolt")); err != nil { t.Fatalf("keypair clone of PRIVATE did not produce a working dir: %v", err) } t.Log("keypair clone of PRIVATE own db succeeded") // Unregister user.creds so later CLI runs are anonymous again. runDolt(t, baseEnv, home, "config", "--global", "--unset", "user.creds") }) // (6) WhoAmI answers with alice's identity for a keypair JWT. t.Run("whoami", func(t *testing.T) { dc, err := creds.GenerateCredentials() if err != nil { t.Fatalf("generate creds: %v", err) } kid := dc.KeyIDBase32Str() if _, err := store.InsertKey(ctx, 1, kid, dc.PubKey, "whoami-key"); err != nil { t.Fatalf("register dolt key: %v", err) } conn, err := grpc.NewClient(credAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { t.Fatalf("dial credentials: %v", err) } defer conn.Close() client := remotesapi.NewCredentialsServiceClient(conn) // aud = the bare host the CLI derives from the endpoint (host:port). host, _, _ := net.SplitHostPort(credAddr) rpc := dc.RPCCreds(host) reqMD, err := rpc.GetRequestMetadata(ctx) if err != nil { t.Fatalf("mint JWT: %v", err) } outCtx := metadata.AppendToOutgoingContext(ctx, "authorization", reqMD["authorization"]) resp, err := client.WhoAmI(outCtx, &remotesapi.WhoAmIRequest{}) if err != nil { t.Fatalf("WhoAmI: %v", err) } if resp.Username != "alice" || resp.EmailAddress != "alice@example.test" { t.Fatalf("WhoAmI returned %+v, want alice/alice@example.test", resp) } // A request with no token is Unauthenticated. if _, err := client.WhoAmI(ctx, &remotesapi.WhoAmIRequest{}); status.Code(err) != codes.Unauthenticated { t.Fatalf("unauthenticated WhoAmI: want Unauthenticated, got %v", err) } }) // (7) ACL RO grantee (bob) can clone the private rodb but cannot push. t.Run("acl_ro_clone_not_push", func(t *testing.T) { bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour)) env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT) dir := t.TempDir() runDolt(t, env, dir, "clone", "--user", "bob", roURL) work := filepath.Join(dir, "rodb") runDolt(t, env, work, "sql", "-q", "create table t(i int primary key); insert into t values (1);") runDolt(t, env, work, "commit", "-Am", "bob change") out, err := tryDolt(env, work, "push", "--user", "bob", "origin", "main") if err == nil { t.Fatalf("RO grantee push unexpectedly succeeded:\n%s", out) } t.Logf("RO grantee push correctly denied:\n%s", out) }) // (8) Push-to-create: alice pushes to a brand-new db that has no repository // row and no store; the server auto-creates a PRIVATE repo + an empty store // and the initial push (a fast-forward from the empty root) succeeds. This is // the end-to-end proof that InitEmptyStore does NOT write an initial commit — // an "Initialize data repository" commit on the remote would make this a // non-fast-forward and the push would be rejected. t.Run("push_to_create", func(t *testing.T) { newURL := fmt.Sprintf("http://%s/~alice/created", addr) env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+pat) // A fresh local db (dolt init writes its own initial commit) to push from. work := filepath.Join(t.TempDir(), "created") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatal(err) } runDolt(t, env, work, "init") runDolt(t, env, work, "sql", "-q", "create table made(i int primary key); insert into made values (42);") runDolt(t, env, work, "commit", "-Am", "created via push") runDolt(t, env, work, "remote", "add", "origin", newURL) runDolt(t, env, work, "push", "--user", "alice", "origin", "main") // The repository row now exists: PRIVATE, owned by alice. created, err := store.GetRepoByOwnerAndName(ctx, "alice", "created") if err != nil { t.Fatalf("auto-created repo row missing after push: %v", err) } if created.Visibility != core.VisibilityPrivate { t.Fatalf("auto-created repo visibility = %s, want PRIVATE", created.Visibility) } if created.OwnerID != 1 { t.Fatalf("auto-created repo owner = %d, want 1 (alice)", created.OwnerID) } // It is PRIVATE, so re-clone with alice's PAT and verify the row landed. dir2 := t.TempDir() runDolt(t, env, dir2, "clone", "--user", "alice", newURL) out := runDolt(t, env, filepath.Join(dir2, "created"), "sql", "-q", "select i from made", "-r", "csv") if !strings.Contains(out, "42") { t.Fatalf("pushed row not present after re-clone; got:\n%s", out) } t.Log("push-to-create succeeded and re-clone returned the pushed row") }) // (9) Push-to-create is refused in another user's namespace: bob (valid RW // PAT) cannot create under ~alice. The push fails and no row is created — the // owner-only guard, which also avoids leaking a stranger's namespace. t.Run("push_to_create_foreign_namespace_denied", func(t *testing.T) { foreignURL := fmt.Sprintf("http://%s/~alice/bobtried", addr) bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour)) env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT) work := filepath.Join(t.TempDir(), "bobtried") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatal(err) } runDolt(t, env, work, "init") runDolt(t, env, work, "sql", "-q", "create table t(i int primary key); insert into t values (1);") runDolt(t, env, work, "commit", "-Am", "bob change") runDolt(t, env, work, "remote", "add", "origin", foreignURL) out, err := tryDolt(env, work, "push", "--user", "bob", "origin", "main") if err == nil { t.Fatalf("bob push-to-create under ~alice unexpectedly succeeded:\n%s", out) } if _, err := store.GetRepoByOwnerAndName(ctx, "alice", "bobtried"); err == nil { t.Fatalf("foreign-namespace push must not create a repository row") } t.Logf("foreign-namespace push-to-create correctly denied:\n%s", out) }) } // --- helpers --- func synthConf(t *testing.T) ini.File { t.Helper() var fk fernet.Key if err := fk.Generate(); err != nil { t.Fatal(err) } seed := make([]byte, 32) if _, err := rand.Read(seed); err != nil { t.Fatal(err) } return ini.File{ "sr.ht": ini.Section{"network-key": fk.Encode()}, "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)}, } } func forgePAT(username, grants string, expires time.Time) string { bt := auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(expires), Grants: grants, Username: username, } return bt.Encode() } func writeJWK(t *testing.T, home string, dc creds.DoltCreds) { t.Helper() dir := filepath.Join(home, ".dolt", "creds") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } data, err := creds.JWKCredSerialize(dc) if err != nil { t.Fatalf("serialize JWK: %v", err) } if err := os.WriteFile(filepath.Join(dir, dc.KeyIDBase32Str()+".jwk"), data, 0o600); err != nil { t.Fatalf("write JWK: %v", err) } } func mkRepo(t *testing.T, ctx context.Context, store *db.Store, reposRoot string, ownerID int, owner, name string, vis core.Visibility) *core.Repo { t.Helper() abs := storage.RepoDiskPath(reposRoot, owner, name) if err := storage.InitStore(ctx, abs, owner, owner+"@example.test"); err != nil { t.Fatalf("init store %s: %v", name, err) } repo, err := store.CreateRepo(ctx, &core.Repo{ Name: name, OwnerID: ownerID, OwnerName: owner, Path: abs, Visibility: vis, }) if err != nil { t.Fatalf("create repo %s: %v", name, err) } return repo } func insertUser(t *testing.T, pool *sql.DB, id int, username string, ut core.UserType) { t.Helper() now := time.Now().UTC() _, err := pool.Exec(` INSERT INTO "user" (id, username, created, updated, email, user_type) VALUES ($1, $2, $3, $3, $4, $5)`, id, username, now, username+"@example.test", string(ut)) if err != nil { t.Fatalf("insert user %s: %v", username, err) } } // startPostgres returns a DSN for a Postgres to test against. It prefers an // existing DOLTSRHT_TEST_PG DSN; otherwise it spins postgres:16-alpine via // Docker. The test skips cleanly if neither is available. func startPostgres(t *testing.T) (dsn string, cleanup func()) { t.Helper() if env := os.Getenv("DOLTSRHT_TEST_PG"); env != "" { return env, func() {} } if _, err := exec.LookPath("docker"); err != nil { t.Skip("neither DOLTSRHT_TEST_PG nor docker available; skipping integration test") } port := freePort(t) name := "doltsrht-it-" + randToken() out, err := exec.Command("docker", "run", "-d", "--name", name, "-e", "POSTGRES_PASSWORD=postgres", "-e", "POSTGRES_DB=doltsrht", "-p", fmt.Sprintf("127.0.0.1:%d:5432", port), "postgres:16-alpine").CombinedOutput() if err != nil { t.Skipf("docker run postgres failed (%v):\n%s", err, out) } cleanup = func() { if o, err := exec.Command("docker", "rm", "-f", name).CombinedOutput(); err != nil { t.Logf("docker rm -f %s: %v\n%s", name, err, o) } } return fmt.Sprintf("postgres://postgres:postgres@127.0.0.1:%d/doltsrht?sslmode=disable", port), cleanup } func waitPG(t *testing.T, pool *sql.DB) { t.Helper() deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { if err := pool.Ping(); err == nil { return } time.Sleep(500 * time.Millisecond) } t.Fatal("postgres never became ready") } func applySchema(t *testing.T, pool *sql.DB) { t.Helper() ddl, err := os.ReadFile("../schema.sql") if err != nil { t.Fatalf("read schema.sql: %v", err) } if _, err := pool.Exec(string(ddl)); err != nil { t.Fatalf("apply schema.sql: %v", err) } } func freePort(t *testing.T) int { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("probe listen: %v", err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port } func freeAddr(t *testing.T) string { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("probe listen: %v", err) } addr := l.Addr().String() l.Close() return addr } func waitTCP(t *testing.T, addr string) { t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err == nil { conn.Close() return } time.Sleep(50 * time.Millisecond) } t.Fatalf("server at %s never became reachable", addr) } func runDolt(t *testing.T, env []string, dir string, args ...string) string { t.Helper() out, err := tryDolt(env, dir, args...) if err != nil { t.Fatalf("dolt %s failed: %v\n%s", strings.Join(args, " "), err, out) } return out } func tryDolt(env []string, dir string, args ...string) (string, error) { cmd := exec.Command(doltBin, args...) cmd.Dir = dir cmd.Env = env out, err := cmd.CombinedOutput() return string(out), err } func randToken() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { panic(err) } return hex.EncodeToString(b) }