diff --git a/apps/backend/internal/sharing/service_integration_test.go b/apps/backend/internal/sharing/service_integration_test.go new file mode 100644 index 0000000..a563ce7 --- /dev/null +++ b/apps/backend/internal/sharing/service_integration_test.go @@ -0,0 +1,365 @@ +//go:build integration + +package sharing + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/get-drexa/drexa/internal/account" + "github.com/get-drexa/drexa/internal/blob" + "github.com/get-drexa/drexa/internal/database" + "github.com/get-drexa/drexa/internal/drive" + "github.com/get-drexa/drexa/internal/organization" + "github.com/get-drexa/drexa/internal/password" + "github.com/get-drexa/drexa/internal/user" + "github.com/get-drexa/drexa/internal/virtualfs" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +func TestService_SharingScopes(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + pg, err := runPostgres(ctx) + if err != nil { + t.Skipf("postgres testcontainer unavailable (docker not running/configured?): %v", err) + } + t.Cleanup(func() { _ = pg.Terminate(ctx) }) + + postgresURL, err := pg.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("postgres connection string: %v", err) + } + + db := database.NewFromPostgres(postgresURL) + t.Cleanup(func() { _ = db.Close() }) + + if err := database.RunMigrations(ctx, db); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + + blobRoot, err := os.MkdirTemp("", "drexa-blobs-*") + if err != nil { + t.Fatalf("temp blob dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(blobRoot) }) + + blobStore := blob.NewFSStore(blob.FSStoreConfig{Root: blobRoot}) + if err := blobStore.Initialize(ctx); err != nil { + t.Fatalf("blob store init: %v", err) + } + + vfs, err := virtualfs.New(blobStore, virtualfs.NewFlatKeyResolver()) + if err != nil { + t.Fatalf("virtualfs.New: %v", err) + } + + userSvc := user.NewService() + orgSvc := organization.NewService() + accSvc := account.NewService() + driveSvc := drive.NewService() + + hashed, err := password.HashString("share-pass") + if err != nil { + t.Fatalf("HashString: %v", err) + } + + testUser, err := userSvc.RegisterUser(ctx, db, user.UserRegistrationOptions{ + Email: "share@example.com", + DisplayName: "Share User", + Password: hashed, + }) + if err != nil { + t.Fatalf("RegisterUser: %v", err) + } + + org, err := orgSvc.CreatePersonalOrganization(ctx, db, "Personal") + if err != nil { + t.Fatalf("CreatePersonalOrganization: %v", err) + } + + acc, err := accSvc.CreateAccount(ctx, db, org.ID, testUser.ID, account.RoleAdmin, account.StatusActive) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + drv, err := driveSvc.CreateDrive(ctx, db, drive.CreateDriveOptions{ + OrgID: org.ID, + OwnerAccountID: &acc.ID, + Name: "My Drive", + QuotaBytes: 1024 * 1024 * 1024, + }) + if err != nil { + t.Fatalf("CreateDrive: %v", err) + } + + root, err := vfs.CreateRootDirectory(ctx, db, drv.ID) + if err != nil { + t.Fatalf("CreateRootDirectory: %v", err) + } + + scope := &virtualfs.Scope{ + DriveID: drv.ID, + RootNodeID: root.ID, + AllowedOps: virtualfs.AllAllowedOps, + AllowedNodes: nil, + ActorKind: virtualfs.ScopeActorAccount, + ActorID: acc.ID, + } + + dirNode, err := vfs.CreateDirectory(ctx, db, root.ID, "Shared Folder", scope) + if err != nil { + t.Fatalf("CreateDirectory: %v", err) + } + + fileNode, err := vfs.CreateFile(ctx, db, virtualfs.CreateFileOptions{ + ParentID: dirNode.ID, + Name: "shared.txt", + }, scope) + if err != nil { + t.Fatalf("CreateFile: %v", err) + } + if err := vfs.WriteFile(ctx, db, fileNode, virtualfs.FileContentFromReader(strings.NewReader("hello")), scope); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + shareSvc, err := NewService(vfs) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + t.Run("create share validation", func(t *testing.T) { + if _, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{}); err != ErrShareNoItems { + t.Fatalf("expected ErrShareNoItems, got %v", err) + } + + if _, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{root}, + }); err != ErrCannotShareRoot { + t.Fatalf("expected ErrCannotShareRoot, got %v", err) + } + + if _, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{dirNode, fileNode}, + }); err != ErrNotSameParent { + t.Fatalf("expected ErrNotSameParent, got %v", err) + } + }) + + dirShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{dirNode}, + }) + if err != nil { + t.Fatalf("CreateShare(dir): %v", err) + } + fileShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + }) + if err != nil { + t.Fatalf("CreateShare(file): %v", err) + } + + t.Run("public scope for directory share", func(t *testing.T) { + scope, err := shareSvc.ResolveScopeForShare(ctx, db, nil, dirShare) + if err != nil { + t.Fatalf("ResolveScopeForShare(dir): %v", err) + } + if scope.ActorKind != virtualfs.ScopeActorShare { + t.Fatalf("unexpected actor kind: got %q want %q", scope.ActorKind, virtualfs.ScopeActorShare) + } + if scope.ActorID != dirShare.ID { + t.Fatalf("unexpected actor id: got %q want %q", scope.ActorID, dirShare.ID) + } + if scope.RootNodeID != dirShare.SharedDirectoryID { + t.Fatalf("unexpected root node: got %q want %q", scope.RootNodeID, dirShare.SharedDirectoryID) + } + if !scope.Allows(virtualfs.OperationRead) { + t.Fatalf("expected read to be allowed") + } + if scope.Allows(virtualfs.OperationWrite) || scope.Allows(virtualfs.OperationUpload) { + t.Fatalf("expected write/upload to be disallowed") + } + if _, ok := scope.AllowedNodes[dirNode.ID]; !ok { + t.Fatalf("expected directory node to be allowlisted") + } + }) + + t.Run("public scope for file share", func(t *testing.T) { + scope, err := shareSvc.ResolveScopeForShare(ctx, db, nil, fileShare) + if err != nil { + t.Fatalf("ResolveScopeForShare(file): %v", err) + } + if scope.ActorKind != virtualfs.ScopeActorShare { + t.Fatalf("unexpected actor kind: got %q want %q", scope.ActorKind, virtualfs.ScopeActorShare) + } + if scope.ActorID != fileShare.ID { + t.Fatalf("unexpected actor id: got %q want %q", scope.ActorID, fileShare.ID) + } + if scope.RootNodeID != dirNode.ID { + t.Fatalf("unexpected root node: got %q want %q", scope.RootNodeID, dirNode.ID) + } + if scope.RootNodeID != fileShare.SharedDirectoryID { + t.Fatalf("unexpected root node: got %q want %q", scope.RootNodeID, fileShare.SharedDirectoryID) + } + if _, ok := scope.AllowedNodes[fileNode.ID]; !ok { + t.Fatalf("expected file node to be allowlisted") + } + }) + + t.Run("list shares includes expired", func(t *testing.T) { + expiredShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + if err != nil { + t.Fatalf("CreateShare(expired list): %v", err) + } + + activeShares, err := shareSvc.ListShares(ctx, db, drv.ID, ListSharesOptions{IncludesExpired: false}) + if err != nil { + t.Fatalf("ListShares(active): %v", err) + } + activeIDs := make(map[string]bool, len(activeShares)) + for _, sh := range activeShares { + activeIDs[sh.ID.String()] = true + } + if activeIDs[expiredShare.ID.String()] { + t.Fatalf("expected expired share to be excluded") + } + if !activeIDs[dirShare.ID.String()] || !activeIDs[fileShare.ID.String()] { + t.Fatalf("expected active shares to be listed") + } + + allShares, err := shareSvc.ListShares(ctx, db, drv.ID, ListSharesOptions{IncludesExpired: true}) + if err != nil { + t.Fatalf("ListShares(all): %v", err) + } + allIDs := make(map[string]bool, len(allShares)) + for _, sh := range allShares { + allIDs[sh.ID.String()] = true + } + if !allIDs[expiredShare.ID.String()] { + t.Fatalf("expected expired share to be included") + } + if !allIDs[dirShare.ID.String()] || !allIDs[fileShare.ID.String()] { + t.Fatalf("expected active shares to be listed") + } + }) + + t.Run("find share not found", func(t *testing.T) { + if _, err := shareSvc.FindShareByPublicID(ctx, db, "missing-share"); err != ErrShareNotFound { + t.Fatalf("expected ErrShareNotFound, got %v", err) + } + }) + + t.Run("expired share", func(t *testing.T) { + expiredShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + if err != nil { + t.Fatalf("CreateShare(expired): %v", err) + } + if _, err := shareSvc.ResolveScopeForShare(ctx, db, nil, expiredShare); err != ErrShareExpired { + t.Fatalf("expected ErrShareExpired, got %v", err) + } + }) + + t.Run("revoked share", func(t *testing.T) { + revokedShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + }) + if err != nil { + t.Fatalf("CreateShare(revoked): %v", err) + } + now := time.Now() + if _, err := db.NewUpdate().Model(revokedShare). + Set("revoked_at = ?", now). + WherePK(). + Exec(ctx); err != nil { + t.Fatalf("update revoked share: %v", err) + } + revokedShare.RevokedAt = &now + if _, err := shareSvc.ResolveScopeForShare(ctx, db, nil, revokedShare); err != ErrShareRevoked { + t.Fatalf("expected ErrShareRevoked, got %v", err) + } + }) + + t.Run("no permissions", func(t *testing.T) { + noPermShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + }) + if err != nil { + t.Fatalf("CreateShare(no permissions): %v", err) + } + if _, err := db.NewDelete(). + Model(&SharePermission{}). + Where("share_id = ?", noPermShare.ID). + Exec(ctx); err != nil { + t.Fatalf("delete permissions: %v", err) + } + if _, err := shareSvc.ResolveScopeForShare(ctx, db, nil, noPermShare); err != ErrNoPermissions { + t.Fatalf("expected ErrNoPermissions, got %v", err) + } + }) + + t.Run("permission expired", func(t *testing.T) { + expiredPermShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + }) + if err != nil { + t.Fatalf("CreateShare(expired perm): %v", err) + } + expiredAt := time.Now().Add(-1 * time.Hour) + if _, err := db.NewUpdate(). + Model(&SharePermission{}). + Set("expires_at = ?", expiredAt). + Where("share_id = ?", expiredPermShare.ID). + Exec(ctx); err != nil { + t.Fatalf("update permission expiry: %v", err) + } + if _, err := shareSvc.ResolveScopeForShare(ctx, db, nil, expiredPermShare); err != ErrShareExpired { + t.Fatalf("expected ErrShareExpired, got %v", err) + } + }) + + t.Run("no items", func(t *testing.T) { + noItemsShare, err := shareSvc.CreateShare(ctx, db, drv.ID, acc.ID, CreateShareOptions{ + Items: []*virtualfs.Node{fileNode}, + }) + if err != nil { + t.Fatalf("CreateShare(no items): %v", err) + } + if _, err := db.NewDelete(). + Model(&ShareItem{}). + Where("share_id = ?", noItemsShare.ID). + Exec(ctx); err != nil { + t.Fatalf("delete share items: %v", err) + } + if _, err := shareSvc.ResolveScopeForShare(ctx, db, nil, noItemsShare); err != ErrShareNoItems { + t.Fatalf("expected ErrShareNoItems, got %v", err) + } + }) +} + +func runPostgres(ctx context.Context) (_ *postgres.PostgresContainer, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("testcontainers panic: %v", r) + } + }() + + return postgres.Run( + ctx, + "postgres:16-alpine", + postgres.WithDatabase("drexa"), + postgres.WithUsername("drexa"), + postgres.WithPassword("drexa"), + postgres.BasicWaitStrategies(), + ) +}