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..5358b609a --- /dev/null +++ b/components/backend/handlers/drive_file_grants.go @@ -0,0 +1,240 @@ +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 := getUserID(c) + + 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, err := h.storage.ListFileGrants(c.Request.Context(), integration.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to list existing file grants", + "details": err.Error(), + }) + return + } + 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 := getUserID(c) + + 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 := getUserID(c) + + 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..da9390970 --- /dev/null +++ b/components/backend/handlers/drive_integration.go @@ -0,0 +1,316 @@ +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" +) + +// getUserID extracts the userID from the Gin context, falling back to +// "default-user" for development environments without auth middleware. +func getUserID(c *gin.Context) string { + if userID := c.GetString("userID"); userID != "" { + return userID + } + return "default-user" +} + +// 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 := getUserID(c) + + // 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, + UserID: 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 := getUserID(c) + + 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 := getUserID(c) + + // 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) + + // 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..67f3b9c87 --- /dev/null +++ b/components/backend/handlers/routes.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "crypto/rand" + "log" + "net/http" + "os" + + "github.com/gin-gonic/gin" +) + +const featureFlagGranularDrivePermissions = "granular-drive-permissions" + +// InitDriveIntegration constructs the Drive integration handlers from +// environment variables and registers them under the project-scoped +// route group. Safe to call when Google OAuth is not configured — the +// routes are still registered but the setup endpoint will fail at runtime +// if credentials are missing. +func InitDriveIntegration(api *gin.RouterGroup) { + clientID := os.Getenv("GOOGLE_OAUTH_CLIENT_ID") + clientSecret := os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET") + apiKey := os.Getenv("GOOGLE_API_KEY") + appID := os.Getenv("GOOGLE_APP_ID") + + // HMAC secret for signing OAuth state parameters. + // Falls back to OAUTH_STATE_SECRET, then generates a random key. + hmacSecret := []byte(os.Getenv("DRIVE_HMAC_SECRET")) + if len(hmacSecret) == 0 { + hmacSecret = []byte(os.Getenv("OAUTH_STATE_SECRET")) + } + if len(hmacSecret) == 0 { + hmacSecret = make([]byte, 32) + if _, err := rand.Read(hmacSecret); err != nil { + log.Fatalf("FATAL: failed to generate HMAC secret for Drive integration: %v", err) + } + log.Printf("WARNING: No DRIVE_HMAC_SECRET or OAUTH_STATE_SECRET configured. Using random secret - OAuth state will not verify across replicas.") + } + + storage := NewDriveStorage(K8sClient, Namespace) + integrationHandler := NewDriveIntegrationHandler(storage, clientID, clientSecret, hmacSecret, apiKey, appID) + fileGrantsHandler := NewDriveFileGrantsHandler(storage, integrationHandler.oauthConfig) + + projectGroup := api.Group("/projects/:projectName", ValidateProjectContext()) + RegisterDriveIntegrationRoutes(projectGroup, integrationHandler, fileGrantsHandler) + + log.Printf("Drive integration routes registered (clientID configured: %v)", clientID != "") +} + +// 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/routes.go b/components/backend/routes.go index 4d5bac93b..413a14fa9 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -13,6 +13,9 @@ func registerRoutes(r *gin.Engine) { { // Public endpoints (no auth required) api.GET("/workflows/ootb", handlers.ListOOTBWorkflows) + + // Google Drive integration (granular permissions) + handlers.InitDriveIntegration(api) // Global runner-types endpoint (no workspace overrides — for admin pages) api.GET("/runner-types", handlers.GetRunnerTypesGlobal) 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/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/page.tsx b/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/page.tsx index a081bea59..2449d8c1c 100644 --- a/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/page.tsx @@ -38,8 +38,8 @@ import { ScheduledSessionRunsTable } from "./_components/scheduled-session-runs- export default function ScheduledSessionDetailPage() { const params = useParams<{ name: string; scheduledSessionName: string }>(); const router = useRouter(); - const projectName = params.name; - const scheduledSessionName = params.scheduledSessionName; + const projectName = params?.name ?? ""; + const scheduledSessionName = params?.scheduledSessionName ?? ""; const { data: scheduledSession, isLoading } = useScheduledSession( projectName, diff --git a/components/frontend/src/app/projects/[name]/scheduled-sessions/new/page.tsx b/components/frontend/src/app/projects/[name]/scheduled-sessions/new/page.tsx index c5a16dfbe..3752fe747 100644 --- a/components/frontend/src/app/projects/[name]/scheduled-sessions/new/page.tsx +++ b/components/frontend/src/app/projects/[name]/scheduled-sessions/new/page.tsx @@ -80,7 +80,7 @@ type FormValues = z.infer; export default function CreateScheduledSessionPage() { const params = useParams<{ name: string }>(); const router = useRouter(); - const projectName = params.name; + const projectName = params?.name ?? ""; const [selectedWorkflow, setSelectedWorkflow] = useState("none"); const [customGitUrl, setCustomGitUrl] = useState(""); 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..c56c84f1e --- /dev/null +++ b/components/frontend/src/components/google-picker/__tests__/file-selection-summary.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FileSelectionSummary } from '../file-selection-summary'; + +interface FileItem { + id: string; + name: string; + mimeType: string; + sizeBytes?: number | null; + isFolder?: boolean; + status?: "active" | "unavailable" | "revoked"; +} + +function makeFile(overrides: Partial = {}): FileItem { + return { + id: 'f1', + name: 'document.pdf', + mimeType: 'application/pdf', + sizeBytes: 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..465e6c738 --- /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?: "active" | "unavailable" | "revoked"; +} + +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 && file.status !== "active" && ( + + {file.status === "revoked" ? "Revoked" : "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..df63b965e --- /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/integrations/google-drive/settings.tsx b/components/frontend/src/components/integrations/google-drive/settings.tsx new file mode 100755 index 000000000..f1880af5d --- /dev/null +++ b/components/frontend/src/components/integrations/google-drive/settings.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Settings, + Unplug, + FileCheck, + AlertCircle, + RefreshCw, +} from "lucide-react"; +import { + useDriveIntegration, + useFileGrants, + useUpdateFileGrants, + useDisconnectDriveIntegration, + type IntegrationStatus, +} from "@/services/drive-api"; +import { + GooglePicker, + type SelectedFile, +} from "@/components/google-picker/google-picker"; +import { FileSelectionSummary } from "@/components/google-picker/file-selection-summary"; + +interface GoogleDriveSettingsPageProps { + projectName: string; + googleApiKey: string; + googleAppId: string; +} + +const statusLabels: Record = { + active: { label: "Active", variant: "default" }, + disconnected: { label: "Disconnected", variant: "destructive" }, + expired: { label: "Expired", variant: "destructive" }, + error: { label: "Error", variant: "destructive" }, +}; + +export default function GoogleDriveSettingsPage({ + projectName, + googleApiKey, + googleAppId, +}: GoogleDriveSettingsPageProps) { + const router = useRouter(); + const [isModifying, setIsModifying] = useState(false); + const [error, setError] = useState(null); + + const integrationQuery = useDriveIntegration(projectName); + const fileGrantsQuery = useFileGrants(projectName); + const updateFileGrants = useUpdateFileGrants(); + const disconnectIntegration = useDisconnectDriveIntegration(); + + const integration = integrationQuery.data; + const fileGrants = fileGrantsQuery.data?.files ?? []; + const hasUnavailableFiles = fileGrants.some( + (f) => f.status === "unavailable" + ); + + const handleModifyFiles = useCallback( + (files: SelectedFile[]) => { + if (files.length === 0) { + setError("Please select at least one file."); + return; + } + + setError(null); + updateFileGrants.mutate( + { + projectName, + files: files.map((f) => ({ + id: f.id, + name: f.name, + mimeType: f.mimeType, + url: f.url, + sizeBytes: f.sizeBytes, + isFolder: f.isFolder, + })), + }, + { + onSuccess: () => { + setIsModifying(false); + }, + onError: (err) => { + setError( + err instanceof Error + ? err.message + : "Failed to update file selection." + ); + }, + } + ); + }, + [projectName, updateFileGrants] + ); + + const handleDisconnect = useCallback(() => { + disconnectIntegration.mutate( + { projectName }, + { + onSuccess: () => { + router.push(`/projects/${projectName}/integrations`); + }, + onError: (err) => { + setError( + err instanceof Error + ? err.message + : "Failed to disconnect integration." + ); + }, + } + ); + }, [projectName, disconnectIntegration, router]); + + if (integrationQuery.isLoading) { + return ( +
+ Loading integration settings... +
+ ); + } + + if (!integration) { + return ( +
+ + + No Google Drive integration found for this project. + + +
+ ); + } + + const statusInfo = statusLabels[integration.status] ?? { + label: integration.status, + variant: "secondary" as const, + }; + + return ( +
+ {/* Integration Status Card */} + + + + + + Google Drive Integration + + {statusInfo.label} + + + Permission scope:{" "} + {integration.permissionScope === "granular" + ? "File-level (only selected files)" + : "Full Drive access (legacy)"} + + + + + {error && ( + + + {error} + + )} + + {integration.status === "disconnected" && ( + + + + This integration has been disconnected. Please re-connect to + restore access. + + + )} + + {hasUnavailableFiles && ( + + + + Some files are no longer available on Google Drive. Consider + updating your file selection. + + + )} + + + + {/* File Grants Card */} + ({ + id: g.googleFileId, + name: g.fileName, + mimeType: g.mimeType, + sizeBytes: g.sizeBytes, + isFolder: g.isFolder, + status: g.status, + }))} + title="Shared Files" + description="These files are accessible to the platform." + /> + + {/* Action Buttons */} +
+ {isModifying ? ( +
+ g.googleFileId)} + onFilesPicked={handleModifyFiles} + onCancel={() => setIsModifying(false)} + buttonLabel="Select Files" + disabled={updateFileGrants.isPending} + /> + {updateFileGrants.isPending && ( +
+ + Updating file selection... +
+ )} +
+ ) : ( + <> + + + + + + + + + Disconnect Google Drive? + + This will revoke all access tokens and remove all file + grants. The platform will no longer be able to access any of + your Google Drive files. You can reconnect later. + + + + Cancel + + Disconnect + + + + + + )} +
+
+ ); +} diff --git a/components/frontend/src/components/integrations/google-drive/setup.tsx b/components/frontend/src/components/integrations/google-drive/setup.tsx new file mode 100755 index 000000000..847f248bc --- /dev/null +++ b/components/frontend/src/components/integrations/google-drive/setup.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Shield, ExternalLink, FileCheck } from "lucide-react"; +import { + useInitDriveSetup, + useDriveCallback, + useUpdateFileGrants, +} from "@/services/drive-api"; +import { + GooglePicker, + type SelectedFile, +} from "@/components/google-picker/google-picker"; +import { FileSelectionSummary } from "@/components/google-picker/file-selection-summary"; + +interface GoogleDriveSetupPageProps { + projectName: string; + googleApiKey: string; + googleAppId: string; +} + +type SetupStep = + | "consent" + | "authenticating" + | "authenticated" + | "selecting" + | "confirming" + | "complete"; + +export default function GoogleDriveSetupPage({ + projectName, + googleApiKey, + googleAppId, +}: GoogleDriveSetupPageProps) { + const [step, setStep] = useState("consent"); + const [selectedFiles, setSelectedFiles] = useState([]); + const [error, setError] = useState(null); + + const searchParams = useSearchParams(); + const initSetup = useInitDriveSetup(); + const driveCallback = useDriveCallback(); + const updateFileGrants = useUpdateFileGrants(); + + // Handle OAuth callback redirect + useEffect(() => { + const code = searchParams?.get("code"); + const state = searchParams?.get("state"); + + if (code && state) { + setStep("authenticating"); + driveCallback.mutate( + { projectName, code, state }, + { + onSuccess: () => { + setStep("authenticated"); + // Clear OAuth params from URL to prevent re-submission on refresh + window.history.replaceState({}, '', window.location.pathname); + }, + onError: (err) => { + setError( + err instanceof Error + ? err.message + : "OAuth callback failed. Please try again." + ); + setStep("consent"); + }, + } + ); + } + }, [searchParams, projectName]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleConnectDrive = useCallback(() => { + setError(null); + const redirectUri = `${window.location.origin}${window.location.pathname}`; + + initSetup.mutate( + { + projectName, + permissionScope: "granular", + redirectUri, + }, + { + onSuccess: (data) => { + window.location.href = data.authUrl; + }, + onError: (err) => { + setError( + err instanceof Error + ? err.message + : "Failed to initiate setup. Please try again." + ); + }, + } + ); + }, [projectName, initSetup]); + + const handleFilesPicked = useCallback( + (files: SelectedFile[]) => { + if (files.length === 0) { + setError("Please select at least one file."); + return; + } + + setSelectedFiles(files); + setStep("confirming"); + }, + [] + ); + + const handleConfirmSelection = useCallback(() => { + setError(null); + updateFileGrants.mutate( + { + projectName, + files: selectedFiles.map((f) => ({ + id: f.id, + name: f.name, + mimeType: f.mimeType, + url: f.url, + sizeBytes: f.sizeBytes, + isFolder: f.isFolder, + })), + }, + { + onSuccess: () => { + setStep("complete"); + }, + onError: (err) => { + setError( + err instanceof Error + ? err.message + : "Failed to save file selection. Please try again." + ); + }, + } + ); + }, [projectName, selectedFiles, updateFileGrants]); + + const handlePickerCancel = useCallback(() => { + setStep("authenticated"); + }, []); + + return ( +
+ + + + + Connect Google Drive + + + Grant access to only the specific Google Drive files you choose. + Your other files remain private and inaccessible. + + + + + {/* Permission explanation */} + + + + Only the specific files you select will be + accessible to this platform. We use Google's file picker so + you choose exactly which files to share — we never see your full + Drive contents. + + + + {error && ( + + {error} + + )} + + {/* Step: Consent — show Connect button */} + {step === "consent" && ( +
+
+

+ When you connect, Google will ask you to authorize access. You + will see the text: +

+
+ “See, edit, create, and delete only the specific Google + Drive files you use with this app” +
+
+ + +
+ )} + + {/* Step: Authenticating — show loading */} + {step === "authenticating" && ( +
+ Completing authentication... +
+ )} + + {/* Step: Authenticated — show Google Picker */} + {step === "authenticated" && ( +
+ + + + Google Drive connected successfully. Now choose the files you + want to share with this platform. + + + + +
+ )} + + {/* Step: Confirming — show selected files and confirm button */} + {step === "confirming" && ( +
+ + +
+ + +
+
+ )} + + {/* Step: Complete — show success */} + {step === "complete" && ( +
+ + + + Google Drive integration is active. Your selected files are now + accessible to this platform. + + + + +
+ )} +
+
+
+ ); +} 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