package remoteapi import ( "context" "errors" "io" "log/slog" "testing" remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1" "github.com/vaughan0/go-ini" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "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" ) // errBoom is a sentinel failure injected into createStore to exercise the // push-to-create rollback path. var errBoom = errors.New("boom") const ( mRoot = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Root" mGetMeta = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetRepoMetadata" mCommit = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Commit" mUpload = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetUploadLocations" mDownload = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetDownloadLocations" mUnknown = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Frobnicate" ) // fakeReq implements repoRequest with a fixed path/id so tests need not depend // on which concrete proto message carries the repo for a given method. type fakeReq struct { path string id *remotesapi.RepoId } func (f fakeReq) GetRepoPath() string { return f.path } func (f fakeReq) GetRepoId() *remotesapi.RepoId { return f.id } // stubStore is an in-memory repoStore: no Postgres. It is a pointer receiver so // tests can inspect recorded auto-create calls after authorize runs. type stubStore struct { repo *core.Repo repoErr error acl *core.AccessMode aclErr error // createErr, when set, is returned by CreateRepo (e.g. db.ErrNameTaken to // simulate losing the first-push race). createErr error // refetchRepo is returned by GetRepoByOwnerAndName after a CreateRepo that // failed with ErrNameTaken (the concurrent winner's row). When nil, repo is // returned instead. refetchRepo *core.Repo // Recorded calls. created *core.Repo // the repo passed to CreateRepo createCalls int deletedID int deleteCalls int getCalls int } func (s *stubStore) GetRepoByOwnerAndName(_ context.Context, _, _ string) (*core.Repo, error) { s.getCalls++ // After a CreateRepo lost the ErrNameTaken race, the re-fetch resolves the // concurrent winner's row. if s.createCalls > 0 && s.refetchRepo != nil { return s.refetchRepo, nil } if s.repoErr != nil { return nil, s.repoErr } return s.repo, nil } func (s *stubStore) EffectiveAccess(_ context.Context, _, _ int) (*core.AccessMode, error) { return s.acl, s.aclErr } func (s *stubStore) CreateRepo(_ context.Context, r *core.Repo) (*core.Repo, error) { s.createCalls++ s.created = r if s.createErr != nil { return nil, s.createErr } out := *r out.ID = 999 return &out, nil } func (s *stubStore) DeleteRepo(_ context.Context, id int) error { s.deleteCalls++ s.deletedID = id return nil } // discardLogger is the logger these tests hand the interceptor: the paths under // test log on their error arms, and a test asserting a gRPC status code has no // use for the line. func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func testInterceptor(store repoStore) *interceptor { return &interceptor{ conf: ini.File{}, service: "dolt.sr.ht", expectedAud: "dolt.srht.bigb.es", logger: discardLogger(), stores: func() repoStore { return store }, reposRoot: "/tmp/repos", createStore: func(context.Context, string) error { return nil }, } } func repo(id, ownerID int, vis core.Visibility) *core.Repo { return &core.Repo{ID: id, Name: "db", OwnerID: ownerID, OwnerName: "alice", Visibility: vis} } func acl(m core.AccessMode) *core.AccessMode { return &m } // patCaller builds an authenticated PAT caller with the given grant string // (empty ⇒ universal token). config context is needed by DecodeGrants. func patCaller(t *testing.T, userID int, grants string) *auth.AuthContext { t.Helper() ctx := config.Context(context.Background(), ini.File{}, "dolt.sr.ht") g, err := auth.DecodeGrants(ctx, grants) if err != nil { t.Fatalf("DecodeGrants(%q): %v", grants, err) } return &auth.AuthContext{ UserID: userID, Username: "alice", UserType: auth.USER_TYPE_USER, AuthMethod: auth.AUTH_OAUTH2, BearerToken: &auth.BearerToken{}, Grants: g, } } // cookieCaller builds an authenticated non-token caller (cookie/keypair): no // BearerToken, so the grant gate passes trivially. func cookieCaller(userID int) *auth.AuthContext { return &auth.AuthContext{ UserID: userID, Username: "alice", UserType: auth.USER_TYPE_USER, AuthMethod: auth.AUTH_COOKIE, } } func wantCode(t *testing.T, err error, code codes.Code) { t.Helper() if status.Code(err) != code { t.Fatalf("want gRPC code %s, got %s (err=%v)", code, status.Code(err), err) } } func TestClassify(t *testing.T) { cases := []struct { method string op core.Op mode core.AccessMode ok bool }{ {mCommit, core.OpPush, core.AccessRW, true}, {mUpload, core.OpPush, core.AccessRW, true}, {mRoot, core.OpCloneRead, core.AccessRO, true}, {mGetMeta, core.OpCloneRead, core.AccessRO, true}, {mDownload, core.OpCloneRead, core.AccessRO, true}, {mUnknown, 0, "", false}, } for _, c := range cases { op, mode, ok := classify(c.method) if ok != c.ok || (ok && (op != c.op || mode != c.mode)) { t.Errorf("classify(%s) = (%v,%v,%v), want (%v,%v,%v)", c.method, op, mode, ok, c.op, c.mode, c.ok) } } } func TestAuthorize(t *testing.T) { ctx := context.Background() req := fakeReq{path: "~alice/db"} t.Run("anonymous public read allowed", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)}) if _, err := i.authorize(ctx, nil, mGetMeta, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("anonymous public push denied (visible)", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)}) _, err := i.authorize(ctx, nil, mCommit, req) wantCode(t, err, codes.PermissionDenied) }) t.Run("anonymous private read is not found", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPrivate)}) _, err := i.authorize(ctx, nil, mGetMeta, req) wantCode(t, err, codes.NotFound) }) t.Run("owner push allowed", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate)}) if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("non-owner private read no acl is not found", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate)}) _, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req) wantCode(t, err, codes.NotFound) }) t.Run("acl RO reads private", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)}) if _, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("acl RO cannot push private (visible ⇒ PermissionDenied)", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)}) _, err := i.authorize(ctx, cookieCaller(7), mCommit, req) wantCode(t, err, codes.PermissionDenied) }) t.Run("acl RW pushes private", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRW)}) if _, err := i.authorize(ctx, cookieCaller(7), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("unknown method denied", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)}) _, err := i.authorize(ctx, nil, mUnknown, req) wantCode(t, err, codes.PermissionDenied) }) t.Run("malformed path is invalid argument", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)}) _, err := i.authorize(ctx, nil, mGetMeta, fakeReq{path: "no-slash"}) wantCode(t, err, codes.InvalidArgument) }) t.Run("missing repo row is not found", func(t *testing.T) { i := testInterceptor(&stubStore{repoErr: db.ErrNotFound}) _, err := i.authorize(ctx, nil, mGetMeta, req) wantCode(t, err, codes.NotFound) }) t.Run("root ping with no path is allowed", func(t *testing.T) { i := testInterceptor(&stubStore{}) got, err := i.authorize(ctx, nil, mRoot, fakeReq{}) if err != nil { t.Fatalf("unexpected error: %v", err) } if got == nil { t.Fatal("expected a caller context") } }) t.Run("non-root with no path is invalid argument", func(t *testing.T) { i := testInterceptor(&stubStore{}) _, err := i.authorize(ctx, nil, mGetMeta, fakeReq{}) wantCode(t, err, codes.InvalidArgument) }) t.Run("repo id shape resolves path", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)}) r := fakeReq{id: &remotesapi.RepoId{Org: "alice", RepoName: "db"}} if _, err := i.authorize(ctx, nil, mGetMeta, r); err != nil { t.Fatalf("unexpected error: %v", err) } }) } func TestAuthorizeGrantGate(t *testing.T) { ctx := context.Background() req := fakeReq{path: "~alice/db"} t.Run("PAT without RO grant denied on read", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) _, err := i.authorize(ctx, patCaller(t, 42, "meta.sr.ht/profile:RO"), mGetMeta, req) wantCode(t, err, codes.PermissionDenied) }) t.Run("PAT with RO grant allowed on read of own repo", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mGetMeta, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("PAT with RO grant cannot push", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mCommit, req) wantCode(t, err, codes.PermissionDenied) }) t.Run("PAT with RW grant pushes own repo", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RW"), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("universal PAT (empty grants) passes gate", func(t *testing.T) { i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)}) if _, err := i.authorize(ctx, patCaller(t, 42, ""), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } }) } // TestAuthorizePushToCreate covers the push-to-create branch of authorize: an // authenticated owner touching a not-yet-existing repo in their own namespace // transparently creates it (PRIVATE row + empty on-disk store), while every // other case keeps returning NotFound and creates nothing. func TestAuthorizePushToCreate(t *testing.T) { ctx := context.Background() req := fakeReq{path: "~alice/newdb"} t.Run("owner create on GetRepoMetadata read", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} var gotPath string i := testInterceptor(st) i.createStore = func(_ context.Context, p string) error { gotPath = p; return nil } // The first RPC of a push is GetRepoMetadata (a read); auto-create fires. if _, err := i.authorize(ctx, cookieCaller(42), mGetMeta, req); err != nil { t.Fatalf("unexpected error: %v", err) } if st.createCalls != 1 { t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls) } if st.created == nil || st.created.Visibility != core.VisibilityPrivate { t.Fatalf("created repo visibility = %v, want PRIVATE", st.created) } if st.created.OwnerID != 42 || st.created.Name != "newdb" || st.created.OwnerName != "alice" { t.Fatalf("created repo = %+v, want owner 42 alice/newdb", st.created) } wantPath := storage.RepoDiskPath("/tmp/repos", "alice", "newdb") if st.created.Path != wantPath || gotPath != wantPath { t.Fatalf("store path: created=%q createStore=%q, want %q", st.created.Path, gotPath, wantPath) } if st.deleteCalls != 0 { t.Fatalf("DeleteRepo calls = %d, want 0", st.deleteCalls) } }) t.Run("owner create then push allowed", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} i := testInterceptor(st) if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } if st.createCalls != 1 { t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls) } }) t.Run("name-taken race re-fetches, no store creation", func(t *testing.T) { winner := &core.Repo{ID: 7, Name: "newdb", OwnerID: 42, OwnerName: "alice", Visibility: core.VisibilityPrivate} st := &stubStore{repoErr: db.ErrNotFound, createErr: db.ErrNameTaken, refetchRepo: winner} var storeCreated bool i := testInterceptor(st) i.createStore = func(context.Context, string) error { storeCreated = true; return nil } if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil { t.Fatalf("unexpected error: %v", err) } if st.createCalls != 1 { t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls) } if storeCreated { t.Fatal("createStore must not be called when the row already exists") } if st.deleteCalls != 0 { t.Fatalf("DeleteRepo calls = %d, want 0", st.deleteCalls) } }) t.Run("store creation failure rolls back row", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} i := testInterceptor(st) i.createStore = func(context.Context, string) error { return errBoom } _, err := i.authorize(ctx, cookieCaller(42), mCommit, req) wantCode(t, err, codes.Unavailable) if st.deleteCalls != 1 || st.deletedID != 999 { t.Fatalf("rollback: deleteCalls=%d deletedID=%d, want 1 and 999", st.deleteCalls, st.deletedID) } }) t.Run("non-owner namespace is not found, no create", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} i := testInterceptor(st) // caller bob touching ~alice/newdb. bob := &auth.AuthContext{UserID: 7, Username: "bob", UserType: auth.USER_TYPE_USER, AuthMethod: auth.AUTH_COOKIE} _, err := i.authorize(ctx, bob, mCommit, req) wantCode(t, err, codes.NotFound) if st.createCalls != 0 { t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls) } }) t.Run("anonymous is not found, no create", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} i := testInterceptor(st) _, err := i.authorize(ctx, nil, mCommit, req) wantCode(t, err, codes.NotFound) if st.createCalls != 0 { t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls) } }) t.Run("suspended owner is not found, no create", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} i := testInterceptor(st) suspended := &auth.AuthContext{UserID: 42, Username: "alice", UserType: auth.USER_TYPE_SUSPENDED, AuthMethod: auth.AUTH_COOKIE} _, err := i.authorize(ctx, suspended, mCommit, req) wantCode(t, err, codes.NotFound) if st.createCalls != 0 { t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls) } }) t.Run("invalid name is not found, no create", func(t *testing.T) { st := &stubStore{repoErr: db.ErrNotFound} i := testInterceptor(st) // "bad name" passes ParseRepoPath (2 non-traversal segments) but fails // core.ValidateName (space is not an allowed character). _, err := i.authorize(ctx, cookieCaller(42), mCommit, fakeReq{path: "~alice/bad name"}) wantCode(t, err, codes.NotFound) if st.createCalls != 0 { t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls) } }) } // TestUnaryAnonymous exercises the full unary path (authenticate + authorize) // for an anonymous request: no metadata ⇒ anonymous caller, no crypto/PG. func TestUnaryAnonymous(t *testing.T) { i := &interceptor{ conf: ini.File{}, service: "dolt.sr.ht", logger: discardLogger(), stores: func() repoStore { return &stubStore{repo: repo(1, 100, core.VisibilityPublic)} }, } var gotCaller bool handler := func(ctx context.Context, _ any) (any, error) { // The handler must see a caller-carrying context (anonymous ⇒ nil ac). gotCaller = authn.CallerFromContext(ctx) == nil return "ok", nil } info := &grpc.UnaryServerInfo{FullMethod: mGetMeta} resp, err := i.unary()(context.Background(), fakeReq{path: "~alice/db"}, info, handler) if err != nil { t.Fatalf("unary: %v", err) } if resp != "ok" || !gotCaller { t.Fatalf("handler not invoked with anonymous caller context (resp=%v)", resp) } } func TestNormalizeAud(t *testing.T) { cases := map[string]string{ "dolt.srht.bigb.es": "dolt.srht.bigb.es", "dolt.srht.bigb.es:443": "dolt.srht.bigb.es", "127.0.0.1:5306": "127.0.0.1", "": "", } for in, want := range cases { if got := normalizeAud(in); got != want { t.Errorf("normalizeAud(%q) = %q, want %q", in, got, want) } } }