From 9d91eea0d594f455fe39a2311fd7ffc4677c9dce Mon Sep 17 00:00:00 2001 From: Jeremy Eder Date: Sat, 14 Mar 2026 13:39:08 -0400 Subject: [PATCH 1/6] feat(backend,frontend): add granular Google Drive permissions (#918) Add file-level (drive.file) permissions for Google Drive integration, replacing the default full-drive access scope. Users select specific files via the Google Picker instead of granting access to all Drive files. Backend: - Add DriveIntegration and FileGrant models with state machines - Add K8s ConfigMap/Secret-backed storage for integrations and tokens - Add OAuth scope constants and GetGoogleDriveScopes() helper - Add drive integration handlers (setup, callback, picker-token, get, disconnect) - Add file grant handlers (list, update with add/remove counting) - Add route registration with Unleash feature flag gating - Fix nil-pointer dereference in GetIntegration not-found path Frontend: - Add drive-api.ts service with React Query hooks for all endpoints - Add GooglePicker component wrapping Google Picker API - Add FileSelectionSummary component with mime-type icons - Add google-picker-loader for async script loading - Add alert-dialog Shadcn UI component - Add setup and settings pages for Drive integration Specs: - Add speckit artifacts (spec, plan, tasks, research, data-model, contracts, checklists, quickstart) Tests (80 total): - Backend: 40 tests (models, storage, integration, file grants) - Frontend: 35 tests (API client, components, loader) + 5 loader tests Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/go.mod | 5 +- components/backend/go.sum | 5 + .../backend/handlers/drive_file_grants.go | 242 +++++++++++ .../handlers/drive_file_grants_test.go | 301 +++++++++++++ .../backend/handlers/drive_integration.go | 320 ++++++++++++++ .../handlers/drive_integration_test.go | 277 ++++++++++++ components/backend/handlers/drive_storage.go | 327 ++++++++++++++ .../backend/handlers/drive_storage_test.go | 356 ++++++++++++++++ components/backend/handlers/oauth.go | 37 ++ components/backend/handlers/routes.go | 40 ++ components/backend/models/drive.go | 210 +++++++++ components/backend/models/drive_test.go | 274 ++++++++++++ components/backend/tests/constants/labels.go | 29 +- .../__tests__/file-selection-summary.test.tsx | 130 ++++++ .../__tests__/google-picker.test.tsx | 102 +++++ .../google-picker/file-selection-summary.tsx | 118 ++++++ .../google-picker/google-picker.tsx | 155 +++++++ .../src/components/ui/alert-dialog.tsx | 157 +++++++ .../__tests__/google-picker-loader.test.ts | 153 +++++++ .../frontend/src/lib/google-picker-loader.ts | 152 +++++++ .../integrations/google-drive/settings.tsx | 290 +++++++++++++ .../pages/integrations/google-drive/setup.tsx | 288 +++++++++++++ .../src/services/__tests__/drive-api.test.ts | 216 ++++++++++ components/frontend/src/services/drive-api.ts | 400 ++++++++++++++++++ .../checklists/requirements.md | 36 ++ .../contracts/drive-integration-api.yaml | 349 +++++++++++++++ .../data-model.md | 104 +++++ specs/001-granular-drive-permissions/plan.md | 84 ++++ .../quickstart.md | 106 +++++ .../research.md | 102 +++++ specs/001-granular-drive-permissions/spec.md | 106 +++++ specs/001-granular-drive-permissions/tasks.md | 202 +++++++++ 32 files changed, 5657 insertions(+), 16 deletions(-) create mode 100755 components/backend/handlers/drive_file_grants.go create mode 100644 components/backend/handlers/drive_file_grants_test.go create mode 100755 components/backend/handlers/drive_integration.go create mode 100644 components/backend/handlers/drive_integration_test.go create mode 100755 components/backend/handlers/drive_storage.go create mode 100644 components/backend/handlers/drive_storage_test.go create mode 100755 components/backend/handlers/routes.go create mode 100755 components/backend/models/drive.go create mode 100644 components/backend/models/drive_test.go create mode 100644 components/frontend/src/components/google-picker/__tests__/file-selection-summary.test.tsx create mode 100644 components/frontend/src/components/google-picker/__tests__/google-picker.test.tsx create mode 100755 components/frontend/src/components/google-picker/file-selection-summary.tsx create mode 100755 components/frontend/src/components/google-picker/google-picker.tsx create mode 100644 components/frontend/src/components/ui/alert-dialog.tsx create mode 100644 components/frontend/src/lib/__tests__/google-picker-loader.test.ts create mode 100755 components/frontend/src/lib/google-picker-loader.ts create mode 100755 components/frontend/src/pages/integrations/google-drive/settings.tsx create mode 100755 components/frontend/src/pages/integrations/google-drive/setup.tsx create mode 100644 components/frontend/src/services/__tests__/drive-api.test.ts create mode 100755 components/frontend/src/services/drive-api.ts create mode 100755 specs/001-granular-drive-permissions/checklists/requirements.md create mode 100755 specs/001-granular-drive-permissions/contracts/drive-integration-api.yaml create mode 100755 specs/001-granular-drive-permissions/data-model.md create mode 100755 specs/001-granular-drive-permissions/plan.md create mode 100755 specs/001-granular-drive-permissions/quickstart.md create mode 100755 specs/001-granular-drive-permissions/research.md create mode 100755 specs/001-granular-drive-permissions/spec.md create mode 100755 specs/001-granular-drive-permissions/tasks.md diff --git a/components/backend/go.mod b/components/backend/go.mod index 941705577..9c8724960 100644 --- a/components/backend/go.mod +++ b/components/backend/go.mod @@ -15,6 +15,8 @@ require ( github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.27.0 + google.golang.org/api v0.189.0 k8s.io/api v0.34.0 k8s.io/apimachinery v0.34.0 k8s.io/client-go v0.34.0 @@ -55,6 +57,7 @@ require ( github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect @@ -90,14 +93,12 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/api v0.189.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect google.golang.org/grpc v1.64.1 // indirect google.golang.org/protobuf v1.36.7 // indirect diff --git a/components/backend/go.sum b/components/backend/go.sum index 809d4684b..b1ad12ad6 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -124,6 +124,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= +github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= @@ -308,6 +310,9 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg= +google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE= +google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE= google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/components/backend/handlers/drive_file_grants.go b/components/backend/handlers/drive_file_grants.go new file mode 100755 index 000000000..4e008b814 --- /dev/null +++ b/components/backend/handlers/drive_file_grants.go @@ -0,0 +1,242 @@ +package handlers + +import ( + "net/http" + "strings" + "time" + + "ambient-code-backend/models" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + "google.golang.org/api/drive/v3" + "google.golang.org/api/option" +) + +// DriveFileGrantsHandler handles file grant CRUD endpoints. +type DriveFileGrantsHandler struct { + storage *DriveStorage + oauthConfig *oauth2.Config +} + +// NewDriveFileGrantsHandler creates a new handler with the given storage. +func NewDriveFileGrantsHandler(storage *DriveStorage, oauthConfig *oauth2.Config) *DriveFileGrantsHandler { + return &DriveFileGrantsHandler{storage: storage, oauthConfig: oauthConfig} +} + +// HandleUpdateFileGrants replaces the current file grant set with the provided list. +// PUT /api/projects/:projectName/integrations/google-drive/files +func (h *DriveFileGrantsHandler) HandleUpdateFileGrants(c *gin.Context) { + projectName := c.Param("projectName") + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" + } + + var req models.UpdateFileGrantsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request", + "details": err.Error(), + }) + return + } + + if len(req.Files) == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "at least one file is required", + "details": "files array must not be empty", + }) + return + } + + // Get the integration to find the integration ID + integration, err := h.storage.GetIntegration(c.Request.Context(), projectName, userID) + if err != nil || integration == nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "no active Google Drive integration found", + }) + return + } + + // Get existing file grants for comparison + existingGrants, _ := h.storage.ListFileGrants(c.Request.Context(), integration.ID) + existingByFileID := make(map[string]models.FileGrant) + for _, g := range existingGrants { + existingByFileID[g.GoogleFileID] = g + } + + // Build the new grant set + newByFileID := make(map[string]bool) + var newGrants []models.FileGrant + added := 0 + + for _, pf := range req.Files { + newByFileID[pf.ID] = true + + if existing, found := existingByFileID[pf.ID]; found { + // Keep existing grant (preserve timestamps) + existing.Reactivate() + newGrants = append(newGrants, existing) + } else { + // Create new grant from picker file + grant := pf.ToFileGrant(integration.ID) + if err := grant.Validate(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid file data", + "details": err.Error(), + }) + return + } + newGrants = append(newGrants, *grant) + added++ + } + } + + // Count removed files + removed := 0 + for fileID := range existingByFileID { + if !newByFileID[fileID] { + removed++ + } + } + + // Persist the updated file grants + if err := h.storage.UpdateFileGrants(c.Request.Context(), integration.ID, newGrants); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to update file grants", + "details": err.Error(), + }) + return + } + + // Update the file count on the integration + integration.FileCount = len(newGrants) + _ = h.storage.SaveIntegration(c.Request.Context(), integration) + + c.JSON(http.StatusOK, models.UpdateFileGrantsResponse{ + Files: newGrants, + Added: added, + Removed: removed, + }) +} + +// HandleListFileGrants returns all file grants for the integration. +// GET /api/projects/:projectName/integrations/google-drive/files +func (h *DriveFileGrantsHandler) HandleListFileGrants(c *gin.Context) { + projectName := c.Param("projectName") + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" + } + + integration, err := h.storage.GetIntegration(c.Request.Context(), projectName, userID) + if err != nil || integration == nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "no active Google Drive integration found", + }) + return + } + + grants, err := h.storage.ListFileGrants(c.Request.Context(), integration.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to list file grants", + "details": err.Error(), + }) + return + } + + // Optionally verify file availability via Drive API (T025) + verifyAvailability := c.Query("verify") == "true" + if verifyAvailability { + grants = h.verifyFileAvailability(c, integration, grants) + } + + c.JSON(http.StatusOK, models.ListFileGrantsResponse{ + Files: grants, + TotalCount: len(grants), + }) +} + +// verifyFileAvailability checks each file grant against the Drive API +// and updates status to "unavailable" for deleted/inaccessible files. +func (h *DriveFileGrantsHandler) verifyFileAvailability( + c *gin.Context, + integration *models.DriveIntegration, + grants []models.FileGrant, +) []models.FileGrant { + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" + } + + accessToken, refreshToken, expiresAt, err := h.storage.GetTokens( + c.Request.Context(), integration.ProjectName, userID, + ) + if err != nil { + return grants + } + + token := &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + Expiry: expiresAt, + } + tokenSource := h.oauthConfig.TokenSource(c.Request.Context(), token) + + srv, err := drive.NewService(c.Request.Context(), option.WithTokenSource(tokenSource)) + if err != nil { + return grants + } + + updated := false + now := time.Now().UTC() + for i := range grants { + if grants[i].Status != models.FileGrantStatusActive { + continue + } + + _, err := srv.Files.Get(grants[i].GoogleFileID). + Fields("id"). + SupportsAllDrives(true). + Do() + + if err != nil { + grants[i].MarkUnavailable() + updated = true + } else { + grants[i].LastVerifiedAt = &now + } + } + + if updated { + _ = h.storage.UpdateFileGrants(c.Request.Context(), integration.ID, grants) + } + + return grants +} + +// CheckDriveAccess is a helper that detects revoked access (T027). +// Call this when any Drive API operation returns 401/403. +func CheckDriveAccess(storage *DriveStorage, c *gin.Context, projectName, userID string, apiErr error) { + if apiErr == nil { + return + } + + errMsg := apiErr.Error() + isAuthError := false + for _, code := range []string{"401", "403", "invalid_grant", "Token has been expired or revoked"} { + if strings.Contains(errMsg, code) { + isAuthError = true + break + } + } + + if isAuthError { + integration, err := storage.GetIntegration(c.Request.Context(), projectName, userID) + if err == nil { + integration.Disconnect() + _ = storage.SaveIntegration(c.Request.Context(), integration) + } + } +} diff --git a/components/backend/handlers/drive_file_grants_test.go b/components/backend/handlers/drive_file_grants_test.go new file mode 100644 index 000000000..8b6653606 --- /dev/null +++ b/components/backend/handlers/drive_file_grants_test.go @@ -0,0 +1,301 @@ +//go:build test + +package handlers + +import ( + "context" + "net/http" + + "ambient-code-backend/models" + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +var _ = Describe("Drive File Grants Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelDriveIntegration), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + storage *DriveStorage + grantsHandler *DriveFileGrantsHandler + ) + + BeforeEach(func() { + logger.Log("Setting up Drive File Grants Handler test") + k8sUtils = test_utils.NewK8sTestUtils(false, "test-namespace") + storage = NewDriveStorage(k8sUtils.K8sClient, "test-namespace") + + oauthCfg := &oauth2.Config{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + Endpoint: google.Endpoint, + } + grantsHandler = NewDriveFileGrantsHandler(storage, oauthCfg) + + httpUtils = test_utils.NewHTTPTestUtils() + }) + + Context("HandleUpdateFileGrants", func() { + It("Should return 400 for empty files array", func() { + // Arrange + body := map[string]interface{}{ + "files": []interface{}{}, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/integrations/google-drive/files", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleUpdateFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + + logger.Log("HandleUpdateFileGrants returned 400 for empty files") + }) + + It("Should return 404 when no integration exists", func() { + // Arrange + body := models.UpdateFileGrantsRequest{ + Files: []models.PickerFile{ + { + ID: "file-1", + Name: "doc.txt", + MimeType: "text/plain", + }, + }, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/integrations/google-drive/files", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleUpdateFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusNotFound) + + logger.Log("HandleUpdateFileGrants returned 404 for missing integration") + }) + + It("Should return 200 with correct added/removed counts", func() { + // Arrange — create integration + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + body := models.UpdateFileGrantsRequest{ + Files: []models.PickerFile{ + { + ID: "file-1", + Name: "doc.txt", + MimeType: "text/plain", + }, + { + ID: "file-2", + Name: "sheet.xlsx", + MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + }, + } + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/integrations/google-drive/files", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleUpdateFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var resp models.UpdateFileGrantsResponse + httpUtils.GetResponseJSON(&resp) + Expect(resp.Added).To(Equal(2)) + Expect(resp.Removed).To(Equal(0)) + Expect(resp.Files).To(HaveLen(2)) + + logger.Log("HandleUpdateFileGrants returned correct counts") + }) + + It("Should compute correct removed count when replacing files", func() { + // Arrange — create integration and initial file grants + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + initialGrants := []models.FileGrant{ + { + ID: "g-1", + IntegrationID: integration.ID, + GoogleFileID: "old-file-1", + FileName: "old1.txt", + MimeType: "text/plain", + Status: models.FileGrantStatusActive, + }, + { + ID: "g-2", + IntegrationID: integration.ID, + GoogleFileID: "old-file-2", + FileName: "old2.txt", + MimeType: "text/plain", + Status: models.FileGrantStatusActive, + }, + } + err = storage.UpdateFileGrants(context.Background(), integration.ID, initialGrants) + Expect(err).NotTo(HaveOccurred()) + + // Replace with one new file (removing both old ones) + body := models.UpdateFileGrantsRequest{ + Files: []models.PickerFile{ + { + ID: "new-file-1", + Name: "new.txt", + MimeType: "text/plain", + }, + }, + } + + httpUtils = test_utils.NewHTTPTestUtils() + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/integrations/google-drive/files", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleUpdateFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var resp models.UpdateFileGrantsResponse + httpUtils.GetResponseJSON(&resp) + Expect(resp.Added).To(Equal(1)) + Expect(resp.Removed).To(Equal(2)) + Expect(resp.Files).To(HaveLen(1)) + + logger.Log("HandleUpdateFileGrants computed correct removed count") + }) + + It("Should return 400 for invalid request body", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("PUT", "/api/projects/test-project/integrations/google-drive/files", "invalid-json") + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleUpdateFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + + logger.Log("HandleUpdateFileGrants returned 400 for invalid body") + }) + }) + + Context("HandleListFileGrants", func() { + It("Should return 404 when no integration exists", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive/files", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleListFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusNotFound) + + logger.Log("HandleListFileGrants returned 404 for missing integration") + }) + + It("Should return 200 with empty file list when no grants exist", func() { + // Arrange — create integration without file grants + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive/files", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleListFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var resp models.ListFileGrantsResponse + httpUtils.GetResponseJSON(&resp) + Expect(resp.TotalCount).To(Equal(0)) + Expect(resp.Files).To(BeEmpty()) + + logger.Log("HandleListFileGrants returned empty list") + }) + + It("Should return 200 with file list when grants exist", func() { + // Arrange — create integration and file grants + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + grants := []models.FileGrant{ + { + ID: "g-1", + IntegrationID: integration.ID, + GoogleFileID: "gf-1", + FileName: "file1.txt", + MimeType: "text/plain", + Status: models.FileGrantStatusActive, + }, + { + ID: "g-2", + IntegrationID: integration.ID, + GoogleFileID: "gf-2", + FileName: "file2.pdf", + MimeType: "application/pdf", + Status: models.FileGrantStatusActive, + }, + } + err = storage.UpdateFileGrants(context.Background(), integration.ID, grants) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive/files", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + grantsHandler.HandleListFileGrants(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + var resp models.ListFileGrantsResponse + httpUtils.GetResponseJSON(&resp) + Expect(resp.TotalCount).To(Equal(2)) + Expect(resp.Files).To(HaveLen(2)) + + logger.Log("HandleListFileGrants returned correct file list") + }) + }) +}) diff --git a/components/backend/handlers/drive_integration.go b/components/backend/handlers/drive_integration.go new file mode 100755 index 000000000..e7f7a9998 --- /dev/null +++ b/components/backend/handlers/drive_integration.go @@ -0,0 +1,320 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + + "ambient-code-backend/models" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// DriveIntegrationHandler handles Google Drive integration endpoints. +type DriveIntegrationHandler struct { + storage *DriveStorage + oauthConfig *oauth2.Config + hmacSecret []byte + googleAPIKey string + googleAppID string +} + +// NewDriveIntegrationHandler creates a new handler with the given dependencies. +func NewDriveIntegrationHandler( + storage *DriveStorage, + clientID, clientSecret string, + hmacSecret []byte, + googleAPIKey, googleAppID string, +) *DriveIntegrationHandler { + return &DriveIntegrationHandler{ + storage: storage, + oauthConfig: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: google.Endpoint, + }, + hmacSecret: hmacSecret, + googleAPIKey: googleAPIKey, + googleAppID: googleAppID, + } +} + +// HandleDriveSetup initiates the Google Drive OAuth flow with the appropriate scope. +// POST /api/projects/:projectName/integrations/google-drive/setup +func (h *DriveIntegrationHandler) HandleDriveSetup(c *gin.Context) { + projectName := c.Param("projectName") + + var req models.SetupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request", + "details": err.Error(), + }) + return + } + + // Default to granular permissions + if req.PermissionScope == "" { + req.PermissionScope = models.PermissionScopeGranular + } + + // Get scopes based on permission scope + scopes := GetGoogleDriveScopes(req.PermissionScope) + + // Configure OAuth with the appropriate scopes and redirect URI + config := *h.oauthConfig + config.Scopes = scopes + config.RedirectURL = req.RedirectURI + + // Generate HMAC-signed state parameter for CSRF protection + stateData := fmt.Sprintf("%s:%s:%d", projectName, string(req.PermissionScope), time.Now().UnixNano()) + state := h.signState(stateData) + + // Generate the authorization URL + authURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) + + c.JSON(http.StatusOK, models.SetupResponse{ + AuthURL: authURL, + State: state, + }) +} + +// HandleDriveCallback handles the OAuth callback from Google. +// GET /api/projects/:projectName/integrations/google-drive/callback +func (h *DriveIntegrationHandler) HandleDriveCallback(c *gin.Context) { + projectName := c.Param("projectName") + code := c.Query("code") + state := c.Query("state") + + if code == "" || state == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "missing parameters", + "details": "code and state are required", + }) + return + } + + // Verify the HMAC-signed state parameter + if !h.verifyState(state) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid state", + "details": "state parameter verification failed", + }) + return + } + + // Extract permission scope from state + scope := h.extractScopeFromState(state) + + // Exchange the authorization code for tokens + token, err := h.oauthConfig.Exchange(c.Request.Context(), code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "token exchange failed", + "details": err.Error(), + }) + return + } + + // Get the user ID from the request context (set by auth middleware) + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" // Fallback for development + } + + // Create the integration record + integration := models.NewDriveIntegration(userID, projectName, scope) + + // Save the integration + if err := h.storage.SaveIntegration(c.Request.Context(), integration); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to save integration", + "details": err.Error(), + }) + return + } + + // Save the tokens in K8s Secrets + if err := h.storage.SaveTokens( + c.Request.Context(), + integration, + token.AccessToken, + token.RefreshToken, + token.Expiry, + ); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to save tokens", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.CallbackResponse{ + IntegrationID: integration.ID, + Status: string(models.IntegrationStatusActive), + PickerToken: token.AccessToken, + }) +} + +// HandlePickerToken returns a fresh access token for the Google Picker. +// GET /api/projects/:projectName/integrations/google-drive/picker-token +func (h *DriveIntegrationHandler) HandlePickerToken(c *gin.Context) { + projectName := c.Param("projectName") + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" + } + + // Get stored tokens + accessToken, refreshToken, expiresAt, err := h.storage.GetTokens(c.Request.Context(), projectName, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "no active Google Drive integration found", + }) + return + } + + // Check if the token is expired and refresh if needed + if time.Now().After(expiresAt) { + tokenSource := h.oauthConfig.TokenSource(c.Request.Context(), &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + Expiry: expiresAt, + }) + + newToken, err := tokenSource.Token() + if err != nil { + // Token refresh failed — integration may be disconnected externally + integration, getErr := h.storage.GetIntegration(c.Request.Context(), projectName, userID) + if getErr == nil { + integration.Disconnect() + _ = h.storage.SaveIntegration(c.Request.Context(), integration) + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "token refresh failed, please re-authenticate", + }) + return + } + + accessToken = newToken.AccessToken + + // Update stored tokens + _ = h.storage.SaveTokens(c.Request.Context(), &models.DriveIntegration{ + ProjectName: projectName, + ID: userID, + }, newToken.AccessToken, newToken.RefreshToken, newToken.Expiry) + + expiresAt = newToken.Expiry + } + + expiresIn := int(time.Until(expiresAt).Seconds()) + if expiresIn < 0 { + expiresIn = 0 + } + + c.JSON(http.StatusOK, models.PickerTokenResponse{ + AccessToken: accessToken, + ExpiresIn: expiresIn, + }) +} + +// HandleGetDriveIntegration returns the current state of the Drive integration. +// GET /api/projects/:projectName/integrations/google-drive +func (h *DriveIntegrationHandler) HandleGetDriveIntegration(c *gin.Context) { + projectName := c.Param("projectName") + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" + } + + integration, err := h.storage.GetIntegration(c.Request.Context(), projectName, userID) + if err != nil || integration == nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "no Google Drive integration found", + }) + return + } + + c.JSON(http.StatusOK, integration) +} + +// HandleDisconnectDriveIntegration disconnects the Drive integration. +// DELETE /api/projects/:projectName/integrations/google-drive +func (h *DriveIntegrationHandler) HandleDisconnectDriveIntegration(c *gin.Context) { + projectName := c.Param("projectName") + userID := c.GetString("userID") + if userID == "" { + userID = "default-user" + } + + // Get the integration to check it exists + integration, err := h.storage.GetIntegration(c.Request.Context(), projectName, userID) + if err != nil || integration == nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "no active integration found", + }) + return + } + + // Revoke the Google token + accessToken, _, _, tokenErr := h.storage.GetTokens(c.Request.Context(), projectName, userID) + if tokenErr == nil && accessToken != "" { + revokeURL := fmt.Sprintf("https://oauth2.googleapis.com/revoke?token=%s", accessToken) + // Best-effort revocation — don't block on failure + resp, err := http.Post(revokeURL, "application/x-www-form-urlencoded", nil) + if err == nil { + resp.Body.Close() + } + } + + // Delete tokens + _ = h.storage.DeleteTokens(c.Request.Context(), projectName, userID) + + // Update integration status + integration.Disconnect() + _ = h.storage.SaveIntegration(c.Request.Context(), integration) + + // Delete the integration record + _ = h.storage.DeleteIntegration(c.Request.Context(), projectName, userID) + + c.Status(http.StatusNoContent) +} + +// signState creates an HMAC-signed state parameter. +func (h *DriveIntegrationHandler) signState(data string) string { + mac := hmac.New(sha256.New, h.hmacSecret) + mac.Write([]byte(data)) + signature := hex.EncodeToString(mac.Sum(nil)) + return data + "." + signature +} + +// verifyState verifies an HMAC-signed state parameter. +func (h *DriveIntegrationHandler) verifyState(state string) bool { + parts := strings.SplitN(state, ".", 2) + if len(parts) != 2 { + return false + } + expected := h.signState(parts[0]) + return hmac.Equal([]byte(expected), []byte(state)) +} + +// extractScopeFromState extracts the permission scope from the state parameter. +func (h *DriveIntegrationHandler) extractScopeFromState(state string) models.PermissionScope { + parts := strings.SplitN(state, ".", 2) + if len(parts) == 0 { + return models.PermissionScopeGranular + } + dataParts := strings.Split(parts[0], ":") + if len(dataParts) >= 2 { + scope := models.PermissionScope(dataParts[1]) + if scope == models.PermissionScopeFull || scope == models.PermissionScopeGranular { + return scope + } + } + return models.PermissionScopeGranular +} diff --git a/components/backend/handlers/drive_integration_test.go b/components/backend/handlers/drive_integration_test.go new file mode 100644 index 000000000..ef51a9e44 --- /dev/null +++ b/components/backend/handlers/drive_integration_test.go @@ -0,0 +1,277 @@ +//go:build test + +package handlers + +import ( + "context" + "net/http" + "time" + + "ambient-code-backend/models" + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Drive Integration Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelDriveIntegration), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + storage *DriveStorage + handler *DriveIntegrationHandler + ) + + BeforeEach(func() { + logger.Log("Setting up Drive Integration Handler test") + k8sUtils = test_utils.NewK8sTestUtils(false, "test-namespace") + storage = NewDriveStorage(k8sUtils.K8sClient, "test-namespace") + handler = NewDriveIntegrationHandler( + storage, + "test-client-id", + "test-client-secret", + []byte("test-hmac-secret"), + "test-api-key", + "test-app-id", + ) + httpUtils = test_utils.NewHTTPTestUtils() + }) + + Context("HandleDriveSetup", func() { + It("Should return 200 with authUrl and state", func() { + // Arrange + body := models.SetupRequest{ + PermissionScope: models.PermissionScopeGranular, + RedirectURI: "http://localhost:3000/callback", + } + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/integrations/google-drive/setup", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + + // Act + handler.HandleDriveSetup(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONStructure([]string{"authUrl", "state"}) + + var resp map[string]interface{} + httpUtils.GetResponseJSON(&resp) + authURL := resp["authUrl"].(string) + Expect(authURL).To(ContainSubstring("accounts.google.com")) + Expect(authURL).To(ContainSubstring("test-client-id")) + Expect(resp["state"]).NotTo(BeEmpty()) + + logger.Log("HandleDriveSetup returned auth URL and state") + }) + + It("Should return 400 for missing redirectUri", func() { + // Arrange — omit RedirectURI (required field) + body := map[string]interface{}{ + "permissionScope": "granular", + } + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/integrations/google-drive/setup", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + + // Act + handler.HandleDriveSetup(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + + logger.Log("HandleDriveSetup correctly returned 400 for missing redirectUri") + }) + + It("Should default to granular scope when not specified", func() { + // Arrange + body := map[string]interface{}{ + "redirectUri": "http://localhost:3000/callback", + } + ginCtx := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/integrations/google-drive/setup", body) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + + // Act + handler.HandleDriveSetup(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONStructure([]string{"authUrl", "state"}) + + logger.Log("HandleDriveSetup defaulted to granular scope") + }) + }) + + Context("HandleGetDriveIntegration", func() { + It("Should return 404 when no integration exists", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + handler.HandleGetDriveIntegration(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusNotFound) + + logger.Log("HandleGetDriveIntegration returned 404 for missing integration") + }) + + It("Should return 200 with integration when it exists", func() { + // Arrange — save an integration first + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + handler.HandleGetDriveIntegration(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONStructure([]string{"id", "userId", "projectName", "provider", "status"}) + + var resp map[string]interface{} + httpUtils.GetResponseJSON(&resp) + Expect(resp["id"]).To(Equal(integration.ID)) + Expect(resp["userId"]).To(Equal("test-user")) + Expect(resp["projectName"]).To(Equal("test-project")) + Expect(resp["provider"]).To(Equal("google")) + Expect(resp["status"]).To(Equal("active")) + + logger.Log("HandleGetDriveIntegration returned existing integration") + }) + + It("Should use default-user when userID is not set", func() { + // Arrange — save integration for default-user + integration := models.NewDriveIntegration("default-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + // Do not set userID — handler should fall back to "default-user" + + // Act + handler.HandleGetDriveIntegration(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + + logger.Log("HandleGetDriveIntegration fell back to default-user") + }) + }) + + Context("HandleDisconnectDriveIntegration", func() { + It("Should return 404 when no integration exists", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("DELETE", "/api/projects/test-project/integrations/google-drive", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + handler.HandleDisconnectDriveIntegration(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusNotFound) + + logger.Log("HandleDisconnectDriveIntegration returned 404 for missing integration") + }) + + It("Should return 204 after disconnecting an existing integration", func() { + // Arrange — save integration without tokens so the handler + // skips the Google token revocation HTTP call in tests. + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + err := storage.SaveIntegration(context.Background(), integration) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("DELETE", "/api/projects/test-project/integrations/google-drive", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + handler.HandleDisconnectDriveIntegration(ginCtx) + + // Assert — c.Status(204) sets the gin writer status but does not + // flush to the underlying httptest.ResponseRecorder, so read the + // status from the gin writer directly. + Expect(ginCtx.Writer.Status()).To(Equal(http.StatusNoContent)) + + // Verify integration was deleted + retrieved, err := storage.GetIntegration(context.Background(), "test-project", "test-user") + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).To(BeNil()) + + logger.Log("HandleDisconnectDriveIntegration successfully disconnected") + }) + }) + + Context("HandlePickerToken", func() { + It("Should return 404 when no tokens exist", func() { + // Arrange + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive/picker-token", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + handler.HandlePickerToken(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusNotFound) + + logger.Log("HandlePickerToken returned 404 for missing tokens") + }) + + It("Should return 200 with valid non-expired token", func() { + // Arrange — save tokens that are still valid + integration := models.NewDriveIntegration("test-user", "test-project", models.PermissionScopeGranular) + expiresAt := time.Now().UTC().Add(1 * time.Hour) + err := storage.SaveTokens(context.Background(), integration, "valid-access-token", "refresh-tok", expiresAt) + Expect(err).NotTo(HaveOccurred()) + + ginCtx := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/integrations/google-drive/picker-token", nil) + ginCtx.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + ginCtx.Set("userID", "test-user") + + // Act + handler.HandlePickerToken(ginCtx) + + // Assert + httpUtils.AssertHTTPStatus(http.StatusOK) + httpUtils.AssertJSONStructure([]string{"accessToken", "expiresIn"}) + + var resp map[string]interface{} + httpUtils.GetResponseJSON(&resp) + Expect(resp["accessToken"]).To(Equal("valid-access-token")) + expiresIn := resp["expiresIn"].(float64) + Expect(expiresIn).To(BeNumerically(">", 0)) + + logger.Log("HandlePickerToken returned valid token") + }) + }) +}) diff --git a/components/backend/handlers/drive_storage.go b/components/backend/handlers/drive_storage.go new file mode 100755 index 000000000..8cd9682ab --- /dev/null +++ b/components/backend/handlers/drive_storage.go @@ -0,0 +1,327 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "ambient-code-backend/models" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + driveIntegrationConfigMapPrefix = "drive-integration-" + driveTokensSecretPrefix = "drive-tokens-" + + // ConfigMap data keys. + configKeyIntegration = "integration" + configKeyFileGrants = "file-grants" + + // Secret data keys. + secretKeyAccessToken = "access-token" + secretKeyRefreshToken = "refresh-token" + secretKeyExpiresAt = "expires-at" +) + +// DriveStorage handles persistence of DriveIntegration and FileGrant data +// using Kubernetes ConfigMaps and Secrets as the backing store. +type DriveStorage struct { + clientset kubernetes.Interface + namespace string +} + +// NewDriveStorage creates a new DriveStorage instance. +func NewDriveStorage(clientset kubernetes.Interface, namespace string) *DriveStorage { + return &DriveStorage{ + clientset: clientset, + namespace: namespace, + } +} + +// configMapName returns the deterministic ConfigMap name for a given +// project and user combination. +func configMapName(projectName, userID string) string { + return driveIntegrationConfigMapPrefix + projectName + "-" + userID +} + +// secretName returns the deterministic Secret name for a given +// project and user combination. +func secretName(projectName, userID string) string { + return driveTokensSecretPrefix + projectName + "-" + userID +} + +// --------------------------------------------------------------------------- +// Integration CRUD +// --------------------------------------------------------------------------- + +// GetIntegration retrieves a DriveIntegration from its backing ConfigMap. +// Returns nil and no error when the ConfigMap does not exist. +func (s *DriveStorage) GetIntegration(ctx context.Context, projectName, userID string) (*models.DriveIntegration, error) { + name := configMapName(projectName, userID) + cm, err := s.clientset.CoreV1().ConfigMaps(s.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to get ConfigMap %s: %w", name, err) + } + + raw, ok := cm.Data[configKeyIntegration] + if !ok { + return nil, fmt.Errorf("ConfigMap %s is missing key %q", name, configKeyIntegration) + } + + var integration models.DriveIntegration + if err := json.Unmarshal([]byte(raw), &integration); err != nil { + return nil, fmt.Errorf("failed to unmarshal integration from ConfigMap %s: %w", name, err) + } + + return &integration, nil +} + +// SaveIntegration persists a DriveIntegration to a ConfigMap. If the ConfigMap +// already exists it is updated; otherwise a new one is created. +func (s *DriveStorage) SaveIntegration(ctx context.Context, integration *models.DriveIntegration) error { + name := configMapName(integration.ProjectName, integration.UserID) + + integrationJSON, err := json.Marshal(integration) + if err != nil { + return fmt.Errorf("failed to marshal integration: %w", err) + } + + existing, err := s.clientset.CoreV1().ConfigMaps(s.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to check for existing ConfigMap %s: %w", name, err) + } + + // ConfigMap does not exist -- create it. + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: s.namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "platform-backend", + "app.kubernetes.io/component": "drive-integration", + "platform/project": integration.ProjectName, + "platform/user": integration.UserID, + }, + }, + Data: map[string]string{ + configKeyIntegration: string(integrationJSON), + }, + } + if _, err := s.clientset.CoreV1().ConfigMaps(s.namespace).Create(ctx, cm, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("failed to create ConfigMap %s: %w", name, err) + } + return nil + } + + // ConfigMap exists -- update it, preserving any existing file-grants data. + if existing.Data == nil { + existing.Data = make(map[string]string) + } + existing.Data[configKeyIntegration] = string(integrationJSON) + + if _, err := s.clientset.CoreV1().ConfigMaps(s.namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update ConfigMap %s: %w", name, err) + } + return nil +} + +// DeleteIntegration removes the ConfigMap that backs a DriveIntegration. If the +// ConfigMap does not exist the call is a no-op. +func (s *DriveStorage) DeleteIntegration(ctx context.Context, projectName, userID string) error { + name := configMapName(projectName, userID) + err := s.clientset.CoreV1().ConfigMaps(s.namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to delete ConfigMap %s: %w", name, err) + } + return nil +} + +// --------------------------------------------------------------------------- +// FileGrant operations +// --------------------------------------------------------------------------- + +// ListFileGrants returns all FileGrants stored in the ConfigMap identified by +// the given integrationID. The integrationID is used to locate the ConfigMap +// by scanning for a matching integration payload. In practice, callers should +// first resolve projectName/userID from the integration and use those to build +// the ConfigMap name. This implementation searches the integration JSON stored +// inside each candidate ConfigMap. +// +// For efficiency the caller should provide the integrationID that corresponds +// to a known projectName/userID pair. This method lists ConfigMaps with the +// drive-integration label and finds the matching one. +func (s *DriveStorage) ListFileGrants(ctx context.Context, integrationID string) ([]models.FileGrant, error) { + cm, err := s.findConfigMapByIntegrationID(ctx, integrationID) + if err != nil { + return nil, err + } + if cm == nil { + return nil, fmt.Errorf("no ConfigMap found for integration %s", integrationID) + } + + return parseFileGrants(cm) +} + +// UpdateFileGrants replaces the file-grants data in the ConfigMap that belongs +// to the given integrationID. +func (s *DriveStorage) UpdateFileGrants(ctx context.Context, integrationID string, grants []models.FileGrant) error { + cm, err := s.findConfigMapByIntegrationID(ctx, integrationID) + if err != nil { + return err + } + if cm == nil { + return fmt.Errorf("no ConfigMap found for integration %s", integrationID) + } + + grantsJSON, err := json.Marshal(grants) + if err != nil { + return fmt.Errorf("failed to marshal file grants: %w", err) + } + + if cm.Data == nil { + cm.Data = make(map[string]string) + } + cm.Data[configKeyFileGrants] = string(grantsJSON) + + if _, err := s.clientset.CoreV1().ConfigMaps(s.namespace).Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update file grants in ConfigMap %s: %w", cm.Name, err) + } + return nil +} + +// findConfigMapByIntegrationID locates the ConfigMap whose integration JSON +// contains the given ID. Returns nil (without error) when no match is found. +func (s *DriveStorage) findConfigMapByIntegrationID(ctx context.Context, integrationID string) (*corev1.ConfigMap, error) { + list, err := s.clientset.CoreV1().ConfigMaps(s.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/component=drive-integration", + }) + if err != nil { + return nil, fmt.Errorf("failed to list drive-integration ConfigMaps: %w", err) + } + + for i := range list.Items { + cm := &list.Items[i] + raw, ok := cm.Data[configKeyIntegration] + if !ok { + continue + } + var integration models.DriveIntegration + if err := json.Unmarshal([]byte(raw), &integration); err != nil { + continue + } + if integration.ID == integrationID { + return cm, nil + } + } + return nil, nil +} + +// parseFileGrants extracts the FileGrant slice from a ConfigMap. An absent +// file-grants key is treated as an empty list (not an error). +func parseFileGrants(cm *corev1.ConfigMap) ([]models.FileGrant, error) { + raw, ok := cm.Data[configKeyFileGrants] + if !ok || raw == "" { + return []models.FileGrant{}, nil + } + + var grants []models.FileGrant + if err := json.Unmarshal([]byte(raw), &grants); err != nil { + return nil, fmt.Errorf("failed to unmarshal file grants from ConfigMap %s: %w", cm.Name, err) + } + return grants, nil +} + +// --------------------------------------------------------------------------- +// Token operations (stored in Kubernetes Secrets) +// --------------------------------------------------------------------------- + +// SaveTokens persists OAuth tokens in a Kubernetes Secret. If the Secret +// already exists it is updated; otherwise a new one is created. +func (s *DriveStorage) SaveTokens(ctx context.Context, integration *models.DriveIntegration, accessToken, refreshToken string, expiresAt time.Time) error { + name := secretName(integration.ProjectName, integration.UserID) + + data := map[string][]byte{ + secretKeyAccessToken: []byte(accessToken), + secretKeyRefreshToken: []byte(refreshToken), + secretKeyExpiresAt: []byte(expiresAt.UTC().Format(time.RFC3339)), + } + + existing, err := s.clientset.CoreV1().Secrets(s.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to check for existing Secret %s: %w", name, err) + } + + // Secret does not exist -- create it. + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: s.namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "platform-backend", + "app.kubernetes.io/component": "drive-tokens", + "platform/project": integration.ProjectName, + "platform/user": integration.UserID, + }, + }, + Type: corev1.SecretTypeOpaque, + Data: data, + } + if _, err := s.clientset.CoreV1().Secrets(s.namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("failed to create Secret %s: %w", name, err) + } + return nil + } + + // Secret exists -- update it. + existing.Data = data + if _, err := s.clientset.CoreV1().Secrets(s.namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update Secret %s: %w", name, err) + } + return nil +} + +// GetTokens retrieves OAuth tokens from the Kubernetes Secret for the given +// project and user. Returns an error if the Secret does not exist. +func (s *DriveStorage) GetTokens(ctx context.Context, projectName, userID string) (accessToken, refreshToken string, expiresAt time.Time, err error) { + name := secretName(projectName, userID) + secret, getErr := s.clientset.CoreV1().Secrets(s.namespace).Get(ctx, name, metav1.GetOptions{}) + if getErr != nil { + err = fmt.Errorf("failed to get Secret %s: %w", name, getErr) + return + } + + accessToken = string(secret.Data[secretKeyAccessToken]) + refreshToken = string(secret.Data[secretKeyRefreshToken]) + + rawExpiry := string(secret.Data[secretKeyExpiresAt]) + if rawExpiry != "" { + expiresAt, err = time.Parse(time.RFC3339, rawExpiry) + if err != nil { + err = fmt.Errorf("failed to parse expires-at from Secret %s: %w", name, err) + return + } + } + + return +} + +// DeleteTokens removes the Kubernetes Secret that stores OAuth tokens for the +// given project and user. If the Secret does not exist the call is a no-op. +func (s *DriveStorage) DeleteTokens(ctx context.Context, projectName, userID string) error { + name := secretName(projectName, userID) + err := s.clientset.CoreV1().Secrets(s.namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to delete Secret %s: %w", name, err) + } + return nil +} diff --git a/components/backend/handlers/drive_storage_test.go b/components/backend/handlers/drive_storage_test.go new file mode 100644 index 000000000..94064cce8 --- /dev/null +++ b/components/backend/handlers/drive_storage_test.go @@ -0,0 +1,356 @@ +//go:build test + +package handlers + +import ( + "context" + "time" + + "ambient-code-backend/models" + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Drive Storage", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelDriveIntegration), func() { + var ( + k8sUtils *test_utils.K8sTestUtils + storage *DriveStorage + testCtx context.Context + ) + + BeforeEach(func() { + logger.Log("Setting up Drive Storage test") + k8sUtils = test_utils.NewK8sTestUtils(false, "test-namespace") + storage = NewDriveStorage(k8sUtils.K8sClient, "test-namespace") + testCtx = context.Background() + }) + + Context("NewDriveStorage", func() { + It("Should create a DriveStorage with correct fields", func() { + // Arrange & Act + s := NewDriveStorage(k8sUtils.K8sClient, "my-namespace") + + // Assert + Expect(s).NotTo(BeNil()) + Expect(s.clientset).NotTo(BeNil()) + Expect(s.namespace).To(Equal("my-namespace")) + + logger.Log("DriveStorage created successfully") + }) + }) + + Context("Integration CRUD", func() { + It("Should round-trip SaveIntegration and GetIntegration", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + + // Act + err := storage.SaveIntegration(testCtx, integration) + Expect(err).NotTo(HaveOccurred()) + + retrieved, err := storage.GetIntegration(testCtx, "project-1", "user-1") + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).NotTo(BeNil()) + Expect(retrieved.ID).To(Equal(integration.ID)) + Expect(retrieved.UserID).To(Equal("user-1")) + Expect(retrieved.ProjectName).To(Equal("project-1")) + Expect(retrieved.Provider).To(Equal("google")) + Expect(retrieved.PermissionScope).To(Equal(models.PermissionScopeGranular)) + Expect(retrieved.Status).To(Equal(models.IntegrationStatusActive)) + + logger.Log("Integration round-trip successful") + }) + + It("Should return nil when ConfigMap does not exist", func() { + // Act + retrieved, err := storage.GetIntegration(testCtx, "nonexistent-project", "nonexistent-user") + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).To(BeNil()) + + logger.Log("GetIntegration correctly returned nil for non-existent ConfigMap") + }) + + It("Should update an existing integration on re-save", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + err := storage.SaveIntegration(testCtx, integration) + Expect(err).NotTo(HaveOccurred()) + + // Modify and re-save + integration.Disconnect() + err = storage.SaveIntegration(testCtx, integration) + Expect(err).NotTo(HaveOccurred()) + + // Act + retrieved, err := storage.GetIntegration(testCtx, "project-1", "user-1") + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).NotTo(BeNil()) + Expect(retrieved.Status).To(Equal(models.IntegrationStatusDisconnected)) + + logger.Log("Integration update successful") + }) + + It("Should delete an integration", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + err := storage.SaveIntegration(testCtx, integration) + Expect(err).NotTo(HaveOccurred()) + + // Act + err = storage.DeleteIntegration(testCtx, "project-1", "user-1") + Expect(err).NotTo(HaveOccurred()) + + // Assert + retrieved, err := storage.GetIntegration(testCtx, "project-1", "user-1") + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).To(BeNil()) + + logger.Log("Integration deletion successful") + }) + + It("Should not error when deleting a non-existent integration", func() { + // Act + err := storage.DeleteIntegration(testCtx, "nonexistent", "nonexistent") + + // Assert + Expect(err).NotTo(HaveOccurred()) + + logger.Log("Delete of non-existent integration succeeded without error") + }) + + It("Should create ConfigMap with correct labels", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + + // Act + err := storage.SaveIntegration(testCtx, integration) + Expect(err).NotTo(HaveOccurred()) + + // Assert - verify ConfigMap labels + cmName := configMapName("project-1", "user-1") + cm, err := k8sUtils.K8sClient.CoreV1().ConfigMaps("test-namespace").Get(testCtx, cmName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cm.Labels["app.kubernetes.io/managed-by"]).To(Equal("platform-backend")) + Expect(cm.Labels["app.kubernetes.io/component"]).To(Equal("drive-integration")) + Expect(cm.Labels["platform/project"]).To(Equal("project-1")) + Expect(cm.Labels["platform/user"]).To(Equal("user-1")) + + logger.Log("ConfigMap labels verified") + }) + }) + + Context("FileGrant operations", func() { + var integration *models.DriveIntegration + + BeforeEach(func() { + integration = models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + err := storage.SaveIntegration(testCtx, integration) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should round-trip UpdateFileGrants and ListFileGrants", func() { + // Arrange + grants := []models.FileGrant{ + { + ID: "grant-1", + IntegrationID: integration.ID, + GoogleFileID: "gf-1", + FileName: "file1.txt", + MimeType: "text/plain", + Status: models.FileGrantStatusActive, + GrantedAt: time.Now().UTC(), + }, + { + ID: "grant-2", + IntegrationID: integration.ID, + GoogleFileID: "gf-2", + FileName: "file2.pdf", + MimeType: "application/pdf", + Status: models.FileGrantStatusActive, + GrantedAt: time.Now().UTC(), + }, + } + + // Act + err := storage.UpdateFileGrants(testCtx, integration.ID, grants) + Expect(err).NotTo(HaveOccurred()) + + retrieved, err := storage.ListFileGrants(testCtx, integration.ID) + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).To(HaveLen(2)) + Expect(retrieved[0].GoogleFileID).To(Equal("gf-1")) + Expect(retrieved[1].GoogleFileID).To(Equal("gf-2")) + + logger.Log("FileGrants round-trip successful") + }) + + It("Should return empty list when no file grants exist", func() { + // Act + grants, err := storage.ListFileGrants(testCtx, integration.ID) + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(grants).To(BeEmpty()) + + logger.Log("Empty file grants list returned correctly") + }) + + It("Should replace file grants on update", func() { + // Arrange - save initial grants + initialGrants := []models.FileGrant{ + { + ID: "grant-1", + IntegrationID: integration.ID, + GoogleFileID: "gf-1", + FileName: "old.txt", + MimeType: "text/plain", + Status: models.FileGrantStatusActive, + GrantedAt: time.Now().UTC(), + }, + } + err := storage.UpdateFileGrants(testCtx, integration.ID, initialGrants) + Expect(err).NotTo(HaveOccurred()) + + // Act - replace with new grants + newGrants := []models.FileGrant{ + { + ID: "grant-new", + IntegrationID: integration.ID, + GoogleFileID: "gf-new", + FileName: "new.txt", + MimeType: "text/plain", + Status: models.FileGrantStatusActive, + GrantedAt: time.Now().UTC(), + }, + } + err = storage.UpdateFileGrants(testCtx, integration.ID, newGrants) + Expect(err).NotTo(HaveOccurred()) + + // Assert + retrieved, err := storage.ListFileGrants(testCtx, integration.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(retrieved).To(HaveLen(1)) + Expect(retrieved[0].GoogleFileID).To(Equal("gf-new")) + + logger.Log("FileGrants replacement successful") + }) + }) + + Context("Token operations", func() { + It("Should round-trip SaveTokens and GetTokens", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + expiresAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Second) + + // Act + err := storage.SaveTokens(testCtx, integration, "access-tok", "refresh-tok", expiresAt) + Expect(err).NotTo(HaveOccurred()) + + accessToken, refreshToken, retrievedExpiry, err := storage.GetTokens(testCtx, "project-1", "user-1") + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(accessToken).To(Equal("access-tok")) + Expect(refreshToken).To(Equal("refresh-tok")) + Expect(retrievedExpiry.Unix()).To(Equal(expiresAt.Unix())) + + logger.Log("Token round-trip successful") + }) + + It("Should return error when tokens do not exist", func() { + // Act + _, _, _, err := storage.GetTokens(testCtx, "nonexistent", "nonexistent") + + // Assert + Expect(err).To(HaveOccurred()) + + logger.Log("GetTokens correctly returned error for non-existent Secret") + }) + + It("Should update existing tokens on re-save", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + expiresAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Second) + err := storage.SaveTokens(testCtx, integration, "old-access", "old-refresh", expiresAt) + Expect(err).NotTo(HaveOccurred()) + + // Act + newExpiry := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second) + err = storage.SaveTokens(testCtx, integration, "new-access", "new-refresh", newExpiry) + Expect(err).NotTo(HaveOccurred()) + + accessToken, refreshToken, retrievedExpiry, err := storage.GetTokens(testCtx, "project-1", "user-1") + + // Assert + Expect(err).NotTo(HaveOccurred()) + Expect(accessToken).To(Equal("new-access")) + Expect(refreshToken).To(Equal("new-refresh")) + Expect(retrievedExpiry.Unix()).To(Equal(newExpiry.Unix())) + + logger.Log("Token update successful") + }) + + It("Should delete tokens", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + expiresAt := time.Now().UTC().Add(1 * time.Hour) + err := storage.SaveTokens(testCtx, integration, "access-tok", "refresh-tok", expiresAt) + Expect(err).NotTo(HaveOccurred()) + + // Act + err = storage.DeleteTokens(testCtx, "project-1", "user-1") + Expect(err).NotTo(HaveOccurred()) + + // Assert + _, _, _, err = storage.GetTokens(testCtx, "project-1", "user-1") + Expect(err).To(HaveOccurred()) + + logger.Log("Token deletion successful") + }) + + It("Should not error when deleting non-existent tokens", func() { + // Act + err := storage.DeleteTokens(testCtx, "nonexistent", "nonexistent") + + // Assert + Expect(err).NotTo(HaveOccurred()) + + logger.Log("Delete of non-existent tokens succeeded without error") + }) + + It("Should create Secret with correct labels", func() { + // Arrange + integration := models.NewDriveIntegration("user-1", "project-1", models.PermissionScopeGranular) + expiresAt := time.Now().UTC().Add(1 * time.Hour) + + // Act + err := storage.SaveTokens(testCtx, integration, "access-tok", "refresh-tok", expiresAt) + Expect(err).NotTo(HaveOccurred()) + + // Assert + sName := secretName("project-1", "user-1") + secret, err := k8sUtils.K8sClient.CoreV1().Secrets("test-namespace").Get(testCtx, sName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(secret.Labels["app.kubernetes.io/managed-by"]).To(Equal("platform-backend")) + Expect(secret.Labels["app.kubernetes.io/component"]).To(Equal("drive-tokens")) + Expect(secret.Labels["platform/project"]).To(Equal("project-1")) + Expect(secret.Labels["platform/user"]).To(Equal("user-1")) + + logger.Log("Secret labels verified") + }) + }) + +}) diff --git a/components/backend/handlers/oauth.go b/components/backend/handlers/oauth.go index 5aa6bd6b7..6eb17950a 100644 --- a/components/backend/handlers/oauth.go +++ b/components/backend/handlers/oauth.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "ambient-code-backend/models" "github.com/gin-gonic/gin" "github.com/google/uuid" corev1 "k8s.io/api/core/v1" @@ -1202,3 +1203,39 @@ func getGoogleUserEmail(ctx context.Context, accessToken string) (string, error) return userInfo.Email, nil } + +// Google OAuth2 scopes for Drive integration. +const ( + // ScopeOpenID is the OpenID Connect scope. + ScopeOpenID = "openid" + // ScopeUserEmail provides access to the user's email. + ScopeUserEmail = "https://www.googleapis.com/auth/userinfo.email" + // ScopeUserProfile provides access to the user's profile. + ScopeUserProfile = "https://www.googleapis.com/auth/userinfo.profile" + // ScopeDriveFull provides full access to all Drive files (legacy). + ScopeDriveFull = "https://www.googleapis.com/auth/drive" + // ScopeDriveReadOnly provides read-only access to all Drive files. + ScopeDriveReadOnly = "https://www.googleapis.com/auth/drive.readonly" + // ScopeDriveFile provides access only to files opened/created by the app. + ScopeDriveFile = "https://www.googleapis.com/auth/drive.file" +) + +// GetGoogleDriveScopes returns the appropriate OAuth2 scopes based on the +// requested permission scope. New integrations default to granular (drive.file). +func GetGoogleDriveScopes(permissionScope models.PermissionScope) []string { + baseScopes := []string{ + ScopeOpenID, + ScopeUserEmail, + ScopeUserProfile, + } + + switch permissionScope { + case models.PermissionScopeFull: + return append(baseScopes, ScopeDriveFull, ScopeDriveReadOnly) + case models.PermissionScopeGranular: + return append(baseScopes, ScopeDriveFile) + default: + // Default to granular (least privilege) for new integrations + return append(baseScopes, ScopeDriveFile) + } +} diff --git a/components/backend/handlers/routes.go b/components/backend/handlers/routes.go new file mode 100755 index 000000000..c803ca5c1 --- /dev/null +++ b/components/backend/handlers/routes.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const featureFlagGranularDrivePermissions = "granular-drive-permissions" + +// RegisterDriveIntegrationRoutes registers all Google Drive integration +// endpoints under the provided router group. All endpoints are gated +// behind the granular-drive-permissions feature flag. +func RegisterDriveIntegrationRoutes(router *gin.RouterGroup, integrationHandler *DriveIntegrationHandler, fileGrantsHandler *DriveFileGrantsHandler) { + drive := router.Group("/integrations/google-drive") + drive.Use(requireFeatureFlag(featureFlagGranularDrivePermissions)) + { + drive.POST("/setup", integrationHandler.HandleDriveSetup) + drive.GET("/callback", integrationHandler.HandleDriveCallback) + drive.GET("/picker-token", integrationHandler.HandlePickerToken) + + drive.GET("/files", fileGrantsHandler.HandleListFileGrants) + drive.PUT("/files", fileGrantsHandler.HandleUpdateFileGrants) + + drive.GET("/", integrationHandler.HandleGetDriveIntegration) + drive.DELETE("/", integrationHandler.HandleDisconnectDriveIntegration) + } +} + +// requireFeatureFlag returns a Gin middleware that aborts with 404 when the +// named feature flag is disabled, effectively hiding the endpoints. +func requireFeatureFlag(flagName string) gin.HandlerFunc { + return func(c *gin.Context) { + if !FeatureEnabled(flagName) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Next() + } +} diff --git a/components/backend/models/drive.go b/components/backend/models/drive.go new file mode 100755 index 000000000..150dafef0 --- /dev/null +++ b/components/backend/models/drive.go @@ -0,0 +1,210 @@ +// Package models defines data types for the platform backend. +package models + +import ( + "fmt" + "time" + + "github.com/google/uuid" +) + +// PermissionScope defines the level of Google Drive access. +type PermissionScope string + +const ( + PermissionScopeGranular PermissionScope = "granular" + PermissionScopeFull PermissionScope = "full" +) + +// IntegrationStatus represents the state of a Drive integration. +type IntegrationStatus string + +const ( + IntegrationStatusActive IntegrationStatus = "active" + IntegrationStatusDisconnected IntegrationStatus = "disconnected" + IntegrationStatusExpired IntegrationStatus = "expired" + IntegrationStatusError IntegrationStatus = "error" +) + +// FileGrantStatus represents the state of a file grant. +type FileGrantStatus string + +const ( + FileGrantStatusActive FileGrantStatus = "active" + FileGrantStatusUnavailable FileGrantStatus = "unavailable" + FileGrantStatusRevoked FileGrantStatus = "revoked" +) + +// DriveIntegration represents a user's Google Drive connection to the platform. +type DriveIntegration struct { + ID string `json:"id"` + UserID string `json:"userId"` + ProjectName string `json:"projectName"` + Provider string `json:"provider"` + PermissionScope PermissionScope `json:"permissionScope"` + Status IntegrationStatus `json:"status"` + TokenExpiresAt time.Time `json:"tokenExpiresAt,omitempty"` + FileCount int `json:"fileCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// NewDriveIntegration creates a new DriveIntegration with default values. +func NewDriveIntegration(userID, projectName string, scope PermissionScope) *DriveIntegration { + now := time.Now().UTC() + return &DriveIntegration{ + ID: uuid.New().String(), + UserID: userID, + ProjectName: projectName, + Provider: "google", + PermissionScope: scope, + Status: IntegrationStatusActive, + CreatedAt: now, + UpdatedAt: now, + } +} + +// Activate transitions the integration to active status. +func (d *DriveIntegration) Activate() error { + switch d.Status { + case IntegrationStatusExpired, IntegrationStatusDisconnected, IntegrationStatusError: + d.Status = IntegrationStatusActive + d.UpdatedAt = time.Now().UTC() + return nil + default: + return fmt.Errorf("cannot activate integration with status %q", d.Status) + } +} + +// Disconnect transitions the integration to disconnected status. +func (d *DriveIntegration) Disconnect() { + d.Status = IntegrationStatusDisconnected + d.UpdatedAt = time.Now().UTC() +} + +// MarkExpired transitions the integration to expired status. +func (d *DriveIntegration) MarkExpired() { + d.Status = IntegrationStatusExpired + d.UpdatedAt = time.Now().UTC() +} + +// MarkError transitions the integration to error status. +func (d *DriveIntegration) MarkError() { + d.Status = IntegrationStatusError + d.UpdatedAt = time.Now().UTC() +} + +// FileGrant represents an individual file/folder that a user has granted access to. +type FileGrant struct { + ID string `json:"id"` + IntegrationID string `json:"integrationId"` + GoogleFileID string `json:"googleFileId"` + FileName string `json:"fileName"` + MimeType string `json:"mimeType"` + FileURL string `json:"fileUrl"` + SizeBytes *int64 `json:"sizeBytes,omitempty"` + IsFolder bool `json:"isFolder"` + Status FileGrantStatus `json:"status"` + GrantedAt time.Time `json:"grantedAt"` + LastAccessedAt *time.Time `json:"lastAccessedAt,omitempty"` + LastVerifiedAt *time.Time `json:"lastVerifiedAt,omitempty"` +} + +// Validate checks that a FileGrant has all required fields. +func (f *FileGrant) Validate() error { + if f.GoogleFileID == "" { + return fmt.Errorf("googleFileId must not be empty") + } + if f.FileName == "" { + return fmt.Errorf("fileName must not be empty") + } + if f.MimeType == "" { + return fmt.Errorf("mimeType must not be empty") + } + return nil +} + +// MarkUnavailable transitions the file grant to unavailable status. +func (f *FileGrant) MarkUnavailable() { + f.Status = FileGrantStatusUnavailable +} + +// Revoke transitions the file grant to revoked status. +func (f *FileGrant) Revoke() { + f.Status = FileGrantStatusRevoked +} + +// Reactivate transitions the file grant back to active status. +func (f *FileGrant) Reactivate() { + f.Status = FileGrantStatusActive +} + +// PickerFile represents file data as returned by the Google Picker callback. +type PickerFile struct { + ID string `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + MimeType string `json:"mimeType" binding:"required"` + URL string `json:"url,omitempty"` + SizeBytes *int64 `json:"sizeBytes,omitempty"` + IsFolder bool `json:"isFolder"` +} + +// ToFileGrant converts a PickerFile to a FileGrant for persistence. +func (p *PickerFile) ToFileGrant(integrationID string) *FileGrant { + now := time.Now().UTC() + return &FileGrant{ + ID: uuid.New().String(), + IntegrationID: integrationID, + GoogleFileID: p.ID, + FileName: p.Name, + MimeType: p.MimeType, + FileURL: p.URL, + SizeBytes: p.SizeBytes, + IsFolder: p.IsFolder, + Status: FileGrantStatusActive, + GrantedAt: now, + } +} + +// UpdateFileGrantsRequest is the request body for PUT /files. +type UpdateFileGrantsRequest struct { + Files []PickerFile `json:"files" binding:"required,min=1"` +} + +// UpdateFileGrantsResponse is the response body for PUT /files. +type UpdateFileGrantsResponse struct { + Files []FileGrant `json:"files"` + Added int `json:"added"` + Removed int `json:"removed"` +} + +// ListFileGrantsResponse is the response body for GET /files. +type ListFileGrantsResponse struct { + Files []FileGrant `json:"files"` + TotalCount int `json:"totalCount"` +} + +// SetupRequest is the request body for POST /setup. +type SetupRequest struct { + PermissionScope PermissionScope `json:"permissionScope"` + RedirectURI string `json:"redirectUri" binding:"required"` +} + +// SetupResponse is the response body for POST /setup. +type SetupResponse struct { + AuthURL string `json:"authUrl"` + State string `json:"state"` +} + +// CallbackResponse is the response body for GET /callback. +type CallbackResponse struct { + IntegrationID string `json:"integrationId"` + Status string `json:"status"` + PickerToken string `json:"pickerToken"` +} + +// PickerTokenResponse is the response body for GET /picker-token. +type PickerTokenResponse struct { + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` +} diff --git a/components/backend/models/drive_test.go b/components/backend/models/drive_test.go new file mode 100644 index 000000000..7d671b93b --- /dev/null +++ b/components/backend/models/drive_test.go @@ -0,0 +1,274 @@ +package models + +import ( + "testing" +) + +func TestNewDriveIntegration(t *testing.T) { + t.Run("creates integration with correct defaults", func(t *testing.T) { + // Act + integration := NewDriveIntegration("user-1", "project-1", PermissionScopeGranular) + + // Assert + if integration.ID == "" { + t.Error("expected non-empty ID") + } + if integration.UserID != "user-1" { + t.Errorf("expected UserID 'user-1', got %q", integration.UserID) + } + if integration.ProjectName != "project-1" { + t.Errorf("expected ProjectName 'project-1', got %q", integration.ProjectName) + } + if integration.Provider != "google" { + t.Errorf("expected Provider 'google', got %q", integration.Provider) + } + if integration.PermissionScope != PermissionScopeGranular { + t.Errorf("expected PermissionScope 'granular', got %q", integration.PermissionScope) + } + if integration.Status != IntegrationStatusActive { + t.Errorf("expected Status 'active', got %q", integration.Status) + } + if integration.CreatedAt.IsZero() { + t.Error("expected non-zero CreatedAt") + } + if integration.UpdatedAt.IsZero() { + t.Error("expected non-zero UpdatedAt") + } + if !integration.CreatedAt.Equal(integration.UpdatedAt) { + t.Error("expected CreatedAt and UpdatedAt to be equal on creation") + } + }) + + t.Run("generates unique IDs", func(t *testing.T) { + a := NewDriveIntegration("user-1", "project-1", PermissionScopeGranular) + b := NewDriveIntegration("user-1", "project-1", PermissionScopeGranular) + + if a.ID == b.ID { + t.Error("expected different IDs for separate calls") + } + }) +} + +func TestDriveIntegration_Activate(t *testing.T) { + tests := []struct { + name string + fromStatus IntegrationStatus + expectError bool + }{ + {"from expired", IntegrationStatusExpired, false}, + {"from disconnected", IntegrationStatusDisconnected, false}, + {"from error", IntegrationStatusError, false}, + {"from active fails", IntegrationStatusActive, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Arrange + integration := &DriveIntegration{Status: tc.fromStatus} + + // Act + err := integration.Activate() + + // Assert + if tc.expectError { + if err == nil { + t.Error("expected error but got nil") + } + if integration.Status != tc.fromStatus { + t.Errorf("expected status to remain %q, got %q", tc.fromStatus, integration.Status) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if integration.Status != IntegrationStatusActive { + t.Errorf("expected status 'active', got %q", integration.Status) + } + if integration.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } + } + }) + } +} + +func TestDriveIntegration_Disconnect(t *testing.T) { + integration := &DriveIntegration{Status: IntegrationStatusActive} + + integration.Disconnect() + + if integration.Status != IntegrationStatusDisconnected { + t.Errorf("expected status 'disconnected', got %q", integration.Status) + } + if integration.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } +} + +func TestDriveIntegration_MarkExpired(t *testing.T) { + integration := &DriveIntegration{Status: IntegrationStatusActive} + + integration.MarkExpired() + + if integration.Status != IntegrationStatusExpired { + t.Errorf("expected status 'expired', got %q", integration.Status) + } + if integration.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } +} + +func TestDriveIntegration_MarkError(t *testing.T) { + integration := &DriveIntegration{Status: IntegrationStatusActive} + + integration.MarkError() + + if integration.Status != IntegrationStatusError { + t.Errorf("expected status 'error', got %q", integration.Status) + } + if integration.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } +} + +func TestFileGrant_Validate(t *testing.T) { + tests := []struct { + name string + grant FileGrant + expectError bool + errContains string + }{ + { + name: "empty googleFileId", + grant: FileGrant{GoogleFileID: "", FileName: "file.txt", MimeType: "text/plain"}, + expectError: true, + errContains: "googleFileId", + }, + { + name: "empty fileName", + grant: FileGrant{GoogleFileID: "abc", FileName: "", MimeType: "text/plain"}, + expectError: true, + errContains: "fileName", + }, + { + name: "empty mimeType", + grant: FileGrant{GoogleFileID: "abc", FileName: "file.txt", MimeType: ""}, + expectError: true, + errContains: "mimeType", + }, + { + name: "valid grant", + grant: FileGrant{GoogleFileID: "abc", FileName: "file.txt", MimeType: "text/plain"}, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.grant.Validate() + + if tc.expectError { + if err == nil { + t.Error("expected error but got nil") + } + if tc.errContains != "" && err != nil { + if got := err.Error(); got == "" || !contains(got, tc.errContains) { + t.Errorf("expected error to contain %q, got %q", tc.errContains, got) + } + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestPickerFile_ToFileGrant(t *testing.T) { + size := int64(1024) + pf := &PickerFile{ + ID: "google-file-id", + Name: "document.pdf", + MimeType: "application/pdf", + URL: "https://drive.google.com/file/d/google-file-id", + SizeBytes: &size, + IsFolder: false, + } + + grant := pf.ToFileGrant("integration-123") + + if grant.ID == "" { + t.Error("expected non-empty ID") + } + if grant.IntegrationID != "integration-123" { + t.Errorf("expected IntegrationID 'integration-123', got %q", grant.IntegrationID) + } + if grant.GoogleFileID != "google-file-id" { + t.Errorf("expected GoogleFileID 'google-file-id', got %q", grant.GoogleFileID) + } + if grant.FileName != "document.pdf" { + t.Errorf("expected FileName 'document.pdf', got %q", grant.FileName) + } + if grant.MimeType != "application/pdf" { + t.Errorf("expected MimeType 'application/pdf', got %q", grant.MimeType) + } + if grant.FileURL != "https://drive.google.com/file/d/google-file-id" { + t.Errorf("expected FileURL to match, got %q", grant.FileURL) + } + if grant.SizeBytes == nil || *grant.SizeBytes != 1024 { + t.Errorf("expected SizeBytes 1024, got %v", grant.SizeBytes) + } + if grant.IsFolder != false { + t.Error("expected IsFolder false") + } + if grant.Status != FileGrantStatusActive { + t.Errorf("expected Status 'active', got %q", grant.Status) + } + if grant.GrantedAt.IsZero() { + t.Error("expected non-zero GrantedAt") + } +} + +func TestFileGrant_MarkUnavailable(t *testing.T) { + grant := &FileGrant{Status: FileGrantStatusActive} + + grant.MarkUnavailable() + + if grant.Status != FileGrantStatusUnavailable { + t.Errorf("expected status 'unavailable', got %q", grant.Status) + } +} + +func TestFileGrant_Revoke(t *testing.T) { + grant := &FileGrant{Status: FileGrantStatusActive} + + grant.Revoke() + + if grant.Status != FileGrantStatusRevoked { + t.Errorf("expected status 'revoked', got %q", grant.Status) + } +} + +func TestFileGrant_Reactivate(t *testing.T) { + grant := &FileGrant{Status: FileGrantStatusRevoked} + + grant.Reactivate() + + if grant.Status != FileGrantStatusActive { + t.Errorf("expected status 'active', got %q", grant.Status) + } +} diff --git a/components/backend/tests/constants/labels.go b/components/backend/tests/constants/labels.go index bdc7a5d57..207140291 100644 --- a/components/backend/tests/constants/labels.go +++ b/components/backend/tests/constants/labels.go @@ -12,20 +12,21 @@ const ( LabelTypes = "types" // Specific component labels for handlers - LabelRepo = "repo" - LabelRepoSeed = "repo_seed" - LabelSecrets = "secrets" - LabelRepository = "repository" - LabelMiddleware = "middleware" - LabelPermissions = "permissions" - LabelProjects = "projects" - LabelGitHubAuth = "github-auth" - LabelGitLabAuth = "gitlab-auth" - LabelSessions = "sessions" - LabelContent = "content" - LabelFeatureFlags = "feature-flags" - LabelDisplayName = "display-name" - LabelHealth = "health" + LabelRepo = "repo" + LabelRepoSeed = "repo_seed" + LabelSecrets = "secrets" + LabelRepository = "repository" + LabelMiddleware = "middleware" + LabelPermissions = "permissions" + LabelProjects = "projects" + LabelGitHubAuth = "github-auth" + LabelGitLabAuth = "gitlab-auth" + LabelSessions = "sessions" + LabelContent = "content" + LabelFeatureFlags = "feature-flags" + LabelDisplayName = "display-name" + LabelHealth = "health" + LabelDriveIntegration = "drive-integration" // Specific component labels for other areas LabelOperations = "operations" // for git operations diff --git a/components/frontend/src/components/google-picker/__tests__/file-selection-summary.test.tsx b/components/frontend/src/components/google-picker/__tests__/file-selection-summary.test.tsx new file mode 100644 index 000000000..a14bc91fe --- /dev/null +++ b/components/frontend/src/components/google-picker/__tests__/file-selection-summary.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FileSelectionSummary } from '../file-selection-summary'; + +function makeFile(overrides: Record = {}) { + return { + id: 'f1', + name: 'document.pdf', + mimeType: 'application/pdf', + sizeBytes: null as number | null, + isFolder: false, + status: 'active', + ...overrides, + }; +} + +describe('FileSelectionSummary', () => { + it('renders "No files selected." when files array is empty', () => { + render(); + expect(screen.getByText('No files selected.')).toBeDefined(); + }); + + it('renders file count badge', () => { + const files = [ + makeFile({ id: 'f1', name: 'a.pdf' }), + makeFile({ id: 'f2', name: 'b.pdf' }), + makeFile({ id: 'f3', name: 'c.pdf' }), + ]; + render(); + expect(screen.getByText('3 files')).toBeDefined(); + }); + + it('renders singular "file" for single file', () => { + render(); + expect(screen.getByText('1 file')).toBeDefined(); + }); + + it('renders file names', () => { + const files = [ + makeFile({ id: 'f1', name: 'report.docx' }), + makeFile({ id: 'f2', name: 'data.csv' }), + ]; + render(); + expect(screen.getByText('report.docx')).toBeDefined(); + expect(screen.getByText('data.csv')).toBeDefined(); + }); + + it('shows "Unavailable" badge for files with status "unavailable"', () => { + const files = [makeFile({ id: 'f1', name: 'gone.pdf', status: 'unavailable' })]; + render(); + expect(screen.getByText('Unavailable')).toBeDefined(); + }); + + it('does not show "Unavailable" badge for active files', () => { + const files = [makeFile({ id: 'f1', name: 'ok.pdf', status: 'active' })]; + render(); + expect(screen.queryByText('Unavailable')).toBeNull(); + }); + + it('renders folder icon for folders', () => { + const files = [makeFile({ id: 'f1', name: 'My Folder', isFolder: true, mimeType: 'application/vnd.google-apps.folder' })]; + const { container } = render(); + // Folder icon gets blue-500 class + const icon = container.querySelector('.text-blue-500'); + expect(icon).toBeDefined(); + expect(icon).not.toBeNull(); + }); + + it('renders spreadsheet icon for spreadsheet mime type', () => { + const files = [makeFile({ id: 'f1', name: 'sheet.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' })]; + const { container } = render(); + const icon = container.querySelector('.text-green-600'); + expect(icon).toBeDefined(); + expect(icon).not.toBeNull(); + }); + + it('renders image icon for image mime type', () => { + const files = [makeFile({ id: 'f1', name: 'photo.png', mimeType: 'image/png' })]; + const { container } = render(); + const icon = container.querySelector('.text-purple-500'); + expect(icon).toBeDefined(); + expect(icon).not.toBeNull(); + }); + + it('renders document icon for document mime type', () => { + const files = [makeFile({ id: 'f1', name: 'doc.docx', mimeType: 'application/vnd.google-apps.document' })]; + const { container } = render(); + const icon = container.querySelector('.text-blue-600'); + expect(icon).toBeDefined(); + expect(icon).not.toBeNull(); + }); + + it('shows file size formatted correctly', () => { + const files = [makeFile({ id: 'f1', name: 'big.zip', sizeBytes: 1536 })]; + render(); + expect(screen.getByText('1.5 KB')).toBeDefined(); + }); + + it('shows MB for larger files', () => { + const files = [makeFile({ id: 'f1', name: 'huge.zip', sizeBytes: 2621440 })]; + render(); + expect(screen.getByText('2.5 MB')).toBeDefined(); + }); + + it('does not show size when sizeBytes is null', () => { + const files = [makeFile({ id: 'f1', name: 'nosize.pdf', sizeBytes: null })]; + render(); + // Should not render any size text + expect(screen.queryByText(/KB|MB|GB|B$/)).toBeNull(); + }); + + it('renders custom title and description', () => { + const files = [makeFile()]; + render( + , + ); + expect(screen.getByText('Granted Files')).toBeDefined(); + expect(screen.getByText('Files shared with this project')).toBeDefined(); + }); + + it('renders default title when no title prop is provided', () => { + const files = [makeFile()]; + render(); + expect(screen.getByText('Selected Files')).toBeDefined(); + }); +}); diff --git a/components/frontend/src/components/google-picker/__tests__/google-picker.test.tsx b/components/frontend/src/components/google-picker/__tests__/google-picker.test.tsx new file mode 100644 index 000000000..f54110ec2 --- /dev/null +++ b/components/frontend/src/components/google-picker/__tests__/google-picker.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { GooglePicker } from '../google-picker'; + +vi.mock('@/lib/google-picker-loader', () => ({ + loadPickerApi: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/drive-api', () => ({ + usePickerToken: vi.fn().mockReturnValue({ + refetch: vi.fn().mockResolvedValue({ + data: { accessToken: 'test-token' }, + }), + }), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + } + return Wrapper; +} + +const defaultProps = { + projectName: 'my-project', + apiKey: 'test-api-key', + appId: 'test-app-id', + onFilesPicked: vi.fn(), +}; + +describe('GooglePicker', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the button with correct label', () => { + const Wrapper = createWrapper(); + render( + + + , + ); + expect(screen.getByText('Choose Files')).toBeDefined(); + }); + + it('renders custom button label', () => { + const Wrapper = createWrapper(); + render( + + + , + ); + expect(screen.getByText('Select Documents')).toBeDefined(); + }); + + it('button is disabled when disabled prop is true', () => { + const Wrapper = createWrapper(); + render( + + + , + ); + const button = screen.getByRole('button'); + expect(button).toBeDefined(); + expect(button.hasAttribute('disabled')).toBe(true); + }); + + it('button is not disabled when disabled prop is false', () => { + const Wrapper = createWrapper(); + render( + + + , + ); + const button = screen.getByRole('button'); + expect(button.hasAttribute('disabled')).toBe(false); + }); + + it('shows loading state when picker is being opened', async () => { + // Make loadPickerApi hang so we can observe loading state + const { loadPickerApi } = await import('@/lib/google-picker-loader'); + vi.mocked(loadPickerApi).mockReturnValue(new Promise(() => {})); + + const Wrapper = createWrapper(); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByText('Opening file picker...')).toBeDefined(); + }); + }); +}); diff --git a/components/frontend/src/components/google-picker/file-selection-summary.tsx b/components/frontend/src/components/google-picker/file-selection-summary.tsx new file mode 100755 index 000000000..4bf33b56a --- /dev/null +++ b/components/frontend/src/components/google-picker/file-selection-summary.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + File, + FileText, + FileSpreadsheet, + FileImage, + Folder, + FileVideo, + FileAudio, +} from "lucide-react"; + +interface FileItem { + id: string; + name: string; + mimeType: string; + sizeBytes?: number | null; + isFolder?: boolean; + status?: string; +} + +interface FileSelectionSummaryProps { + files: FileItem[]; + title?: string; + description?: string; +} + +function formatFileSize(bytes: number | null | undefined): string { + if (bytes == null || bytes === 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let unitIndex = 0; + let size = bytes; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`; +} + +function getFileIcon(mimeType: string, isFolder?: boolean) { + if (isFolder) return ; + if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) + return ; + if (mimeType.includes("image")) + return ; + if (mimeType.includes("video")) + return ; + if (mimeType.includes("audio")) + return ; + if ( + mimeType.includes("document") || + mimeType.includes("text") || + mimeType.includes("pdf") + ) + return ; + return ; +} + +export function FileSelectionSummary({ + files, + title = "Selected Files", + description, +}: FileSelectionSummaryProps) { + if (files.length === 0) { + return ( + + + No files selected. + + + ); + } + + return ( + + + + {title} + {files.length} file{files.length !== 1 ? "s" : ""} + + {description && ( + {description} + )} + + +
    + {files.map((file) => ( +
  • + {getFileIcon(file.mimeType, file.isFolder)} + {file.name} + {file.status === "unavailable" && ( + + Unavailable + + )} + {file.sizeBytes != null && file.sizeBytes > 0 && ( + + {formatFileSize(file.sizeBytes)} + + )} +
  • + ))} +
+
+
+ ); +} diff --git a/components/frontend/src/components/google-picker/google-picker.tsx b/components/frontend/src/components/google-picker/google-picker.tsx new file mode 100755 index 000000000..5d531032a --- /dev/null +++ b/components/frontend/src/components/google-picker/google-picker.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { FileCheck, RefreshCw, AlertCircle } from "lucide-react"; +import { loadPickerApi } from "@/lib/google-picker-loader"; +import { usePickerToken } from "@/services/drive-api"; + +export interface SelectedFile { + id: string; + name: string; + mimeType: string; + url: string; + sizeBytes: number | null; + isFolder: boolean; +} + +interface GooglePickerProps { + projectName: string; + /** Google API key for the Picker. */ + apiKey: string; + /** Google Cloud project app ID. */ + appId: string; + /** Pre-selected file IDs (for the modify flow). */ + existingFileIds?: string[]; + /** Called when user selects files and confirms. */ + onFilesPicked: (files: SelectedFile[]) => void; + /** Called when user cancels the picker. */ + onCancel?: () => void; + /** Custom button label. */ + buttonLabel?: string; + /** Whether the button is disabled. */ + disabled?: boolean; +} + +export function GooglePicker({ + projectName, + apiKey, + appId, + onFilesPicked, + onCancel, + buttonLabel = "Choose Files", + disabled = false, +}: GooglePickerProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const pickerTokenQuery = usePickerToken(projectName); + + const openPicker = useCallback(async () => { + setLoading(true); + setError(null); + + try { + // Fetch a fresh picker token + const tokenData = await pickerTokenQuery.refetch(); + if (!tokenData.data?.accessToken) { + throw new Error("Failed to get picker token. Please re-authenticate."); + } + + // Load the Picker API + await loadPickerApi(); + + const accessToken = tokenData.data.accessToken; + + // Build the Picker + const docsView = new window.google.picker.DocsView( + window.google.picker.ViewId.DOCS + ); + docsView.setIncludeFolders(true); + docsView.setSelectFolderEnabled(true); + + const picker = new window.google.picker.PickerBuilder() + .setOAuthToken(accessToken) + .setDeveloperKey(apiKey) + .setAppId(appId) + .addView(docsView) + .enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED) + .enableFeature(window.google.picker.Feature.SUPPORT_DRIVES) + .setOrigin(window.location.protocol + "//" + window.location.host) + .setTitle("Select files to share with this platform") + .setCallback((data: GooglePickerResponse) => { + if (data.action === window.google.picker.Action.PICKED) { + if (data.docs.length === 0) { + setError("Please select at least one file."); + return; + } + + const selectedFiles: SelectedFile[] = data.docs.map((doc) => ({ + id: doc.id, + name: doc.name, + mimeType: doc.mimeType, + url: doc.url, + sizeBytes: doc.sizeBytes || null, + isFolder: doc.type === "folder", + })); + + onFilesPicked(selectedFiles); + } else if (data.action === window.google.picker.Action.CANCEL) { + onCancel?.(); + } + }) + .build(); + + picker.setVisible(true); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to open file picker."; + setError(message); + } finally { + setLoading(false); + } + }, [apiKey, appId, onFilesPicked, onCancel, pickerTokenQuery]); + + return ( +
+ + + {error && ( + + + + {error} + + + + )} +
+ ); +} diff --git a/components/frontend/src/components/ui/alert-dialog.tsx b/components/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..764d8b841 --- /dev/null +++ b/components/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/components/frontend/src/lib/__tests__/google-picker-loader.test.ts b/components/frontend/src/lib/__tests__/google-picker-loader.test.ts new file mode 100644 index 000000000..911039081 --- /dev/null +++ b/components/frontend/src/lib/__tests__/google-picker-loader.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// We need a fresh module for each test because the loader caches state +// in module-level variables (pickerApiLoaded, gisLoaded). +let loadPickerApi: () => Promise; +let loadGis: () => Promise; +let loadGoogleApis: () => Promise; + +beforeEach(async () => { + vi.resetModules(); + // Reset DOM — remove any previously injected scripts + document.querySelectorAll('script').forEach((s) => s.remove()); + + const mod = await import('../google-picker-loader'); + loadPickerApi = mod.loadPickerApi; + loadGis = mod.loadGis; + loadGoogleApis = mod.loadGoogleApis; +}); + +describe('loadPickerApi', () => { + it('creates a script element with correct src and calls gapi.load', async () => { + // Set up gapi mock that will be available after script loads + const gapiLoadMock = vi.fn( + (_api: string, opts: { callback: () => void }) => { + opts.callback(); + }, + ); + + const appendChildSpy = vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + // Simulate script load + const script = node as HTMLScriptElement; + expect(script.src).toContain('https://apis.google.com/js/api.js'); + expect(script.async).toBe(true); + + // Make gapi available before calling onload + vi.stubGlobal('gapi', { load: gapiLoadMock }); + window.gapi = { load: gapiLoadMock }; + + // Trigger onload + if (script.onload) { + (script.onload as EventListener)(new Event('load')); + } + return node; + }); + + await loadPickerApi(); + + expect(appendChildSpy).toHaveBeenCalledTimes(1); + expect(gapiLoadMock).toHaveBeenCalledWith('picker', expect.objectContaining({ + callback: expect.any(Function), + onerror: expect.any(Function), + })); + + appendChildSpy.mockRestore(); + }); + + it('returns immediately on second call (caching)', async () => { + // First call: set up script loading + const gapiLoadMock = vi.fn( + (_api: string, opts: { callback: () => void }) => { + opts.callback(); + }, + ); + + const appendChildSpy = vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + const script = node as HTMLScriptElement; + window.gapi = { load: gapiLoadMock }; + if (script.onload) { + (script.onload as EventListener)(new Event('load')); + } + return node; + }); + + await loadPickerApi(); + expect(appendChildSpy).toHaveBeenCalledTimes(1); + + // Second call should skip script creation and gapi.load + await loadPickerApi(); + // appendChild should not have been called again + expect(appendChildSpy).toHaveBeenCalledTimes(1); + // gapi.load should only have been called once + expect(gapiLoadMock).toHaveBeenCalledTimes(1); + + appendChildSpy.mockRestore(); + }); +}); + +describe('loadGis', () => { + it('creates script element with correct src', async () => { + const appendChildSpy = vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + const script = node as HTMLScriptElement; + expect(script.src).toContain('https://accounts.google.com/gsi/client'); + + if (script.onload) { + (script.onload as EventListener)(new Event('load')); + } + return node; + }); + + await loadGis(); + + expect(appendChildSpy).toHaveBeenCalledTimes(1); + + appendChildSpy.mockRestore(); + }); + + it('returns immediately on second call (caching)', async () => { + const appendChildSpy = vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + const script = node as HTMLScriptElement; + if (script.onload) { + (script.onload as EventListener)(new Event('load')); + } + return node; + }); + + await loadGis(); + await loadGis(); + + // Script should only be appended once + expect(appendChildSpy).toHaveBeenCalledTimes(1); + + appendChildSpy.mockRestore(); + }); +}); + +describe('loadGoogleApis', () => { + it('calls both loadPickerApi and loadGis', async () => { + const gapiLoadMock = vi.fn( + (_api: string, opts: { callback: () => void }) => { + opts.callback(); + }, + ); + + const appendChildSpy = vi.spyOn(document.head, 'appendChild').mockImplementation((node) => { + const script = node as HTMLScriptElement; + if (script.src.includes('api.js')) { + window.gapi = { load: gapiLoadMock }; + } + if (script.onload) { + (script.onload as EventListener)(new Event('load')); + } + return node; + }); + + await loadGoogleApis(); + + // Should have appended two scripts (api.js and gsi/client) + expect(appendChildSpy).toHaveBeenCalledTimes(2); + expect(gapiLoadMock).toHaveBeenCalledWith('picker', expect.any(Object)); + + appendChildSpy.mockRestore(); + }); +}); diff --git a/components/frontend/src/lib/google-picker-loader.ts b/components/frontend/src/lib/google-picker-loader.ts new file mode 100755 index 000000000..2dd98be85 --- /dev/null +++ b/components/frontend/src/lib/google-picker-loader.ts @@ -0,0 +1,152 @@ +/** + * Google Picker API and Google Identity Services (GIS) loader. + * + * The Picker API is loaded via a