//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)
}