feat(integrations): single workspace switcher with deep links and copy-config dialog (#1616)
This commit is contained in:
@@ -92,6 +92,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.POST("/action-presets/reset", c.httpResetActionPresets)
|
||||
api.GET("/workspace-settings", c.httpGetWorkspaceSettings)
|
||||
api.PUT("/workspace-settings", c.httpUpdateWorkspaceSettings)
|
||||
api.POST("/workspace-settings/copy", c.httpCopyWorkspaceSettings)
|
||||
|
||||
api.GET("/stats", c.httpGetStats)
|
||||
}
|
||||
@@ -898,6 +899,41 @@ func (c *Controller) httpUpdateWorkspaceSettings(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// copyWorkspaceSettingsRequest carries the target workspace for a copy. The
|
||||
// source workspace comes from the workspace_id query param, matching the other
|
||||
// integration copy endpoints.
|
||||
type copyWorkspaceSettingsRequest struct {
|
||||
TargetWorkspaceID string `json:"targetWorkspaceId"`
|
||||
}
|
||||
|
||||
func (c *Controller) httpCopyWorkspaceSettings(ctx *gin.Context) {
|
||||
source := strings.TrimSpace(ctx.Query("workspace_id"))
|
||||
if source == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
|
||||
return
|
||||
}
|
||||
var req copyWorkspaceSettingsRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TargetWorkspaceID) == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"})
|
||||
return
|
||||
}
|
||||
settings, err := c.service.CopyWorkspaceSettingsToWorkspace(ctx.Request.Context(), source, req.TargetWorkspaceID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrWorkspaceSettingsValidation):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
ctx.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// parsePaginationQuery reads `page` and `per_page` query params. Missing or
|
||||
// non-positive values fall back to defaults (page=1, perPage=50). The upper
|
||||
// bound for perPage (100) is applied later by clampSearchPage in the client
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
|
||||
// from.
|
||||
var ErrSameWorkspace = errors.New("github: source and target workspaces are the same")
|
||||
|
||||
// CopyWorkspaceSettingsToWorkspace copies the per-workspace GitHub operational
|
||||
// settings (repo scope + saved/default query presets) and the workspace's
|
||||
// quick-action presets from sourceWorkspaceID to targetWorkspaceID. GitHub
|
||||
// authentication is install-wide, so there are no credentials to copy — only the
|
||||
// workspace-scoped settings. Watchers are intentionally out of scope.
|
||||
func (s *Service) CopyWorkspaceSettingsToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*WorkspaceSettings, error) {
|
||||
sourceWorkspaceID = strings.TrimSpace(sourceWorkspaceID)
|
||||
targetWorkspaceID = strings.TrimSpace(targetWorkspaceID)
|
||||
if sourceWorkspaceID == "" || targetWorkspaceID == "" {
|
||||
return nil, fmt.Errorf("%w: source and target workspace ids are required", ErrWorkspaceSettingsValidation)
|
||||
}
|
||||
if sourceWorkspaceID == targetWorkspaceID {
|
||||
return nil, ErrSameWorkspace
|
||||
}
|
||||
if s.store == nil {
|
||||
return nil, fmt.Errorf("github store not configured")
|
||||
}
|
||||
// GetWorkspaceSettings returns defaults (never nil) for a missing row.
|
||||
source, err := s.store.GetWorkspaceSettings(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source github settings: %w", err)
|
||||
}
|
||||
target := &WorkspaceSettings{
|
||||
WorkspaceID: targetWorkspaceID,
|
||||
RepoScopeMode: source.RepoScopeMode,
|
||||
RepoScopeOrgs: append([]string(nil), source.RepoScopeOrgs...),
|
||||
RepoScopeRepos: append([]RepoFilter(nil), source.RepoScopeRepos...),
|
||||
SavedPresets: cloneRawMessage(source.SavedPresets),
|
||||
DefaultQueryPresets: cloneRawMessage(source.DefaultQueryPresets),
|
||||
}
|
||||
// Copy the action presets before the workspace-settings write. These are two
|
||||
// separate store writes without a shared transaction, so ordering the
|
||||
// preset copy first means a preset failure leaves the target completely
|
||||
// untouched; only a failure of the final settings write can leave a partial
|
||||
// copy, and a full retry overwrites both.
|
||||
if err := s.copyActionPresets(ctx, sourceWorkspaceID, targetWorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.UpsertWorkspaceSettings(ctx, target); err != nil {
|
||||
return nil, fmt.Errorf("write target github settings: %w", err)
|
||||
}
|
||||
return s.store.GetWorkspaceSettings(ctx, targetWorkspaceID)
|
||||
}
|
||||
|
||||
// copyActionPresets duplicates the source workspace's stored quick-action
|
||||
// presets onto the target. When the source has no stored row it relies on
|
||||
// built-in defaults, so the target's row is cleared as well — otherwise a copy
|
||||
// would leave the target on its own customised presets while reporting success,
|
||||
// diverging from the source. Both then fall back to the same defaults on read.
|
||||
func (s *Service) copyActionPresets(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) error {
|
||||
stored, err := s.store.GetActionPresets(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source github action presets: %w", err)
|
||||
}
|
||||
if stored == nil {
|
||||
if err := s.store.DeleteActionPresets(ctx, targetWorkspaceID); err != nil {
|
||||
return fmt.Errorf("clear target github action presets: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
target := &ActionPresets{
|
||||
WorkspaceID: targetWorkspaceID,
|
||||
PR: append([]ActionPreset(nil), stored.PR...),
|
||||
Issue: append([]ActionPreset(nil), stored.Issue...),
|
||||
}
|
||||
if err := s.store.UpsertActionPresets(ctx, target); err != nil {
|
||||
return fmt.Errorf("write target github action presets: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newCopyTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
client := NewMockClient()
|
||||
store := newTestStore(t)
|
||||
return NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
|
||||
}
|
||||
|
||||
func TestCopyWorkspaceSettingsToWorkspace_CopiesSettings(t *testing.T) {
|
||||
svc := newCopyTestService(t)
|
||||
ctx := context.Background()
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
|
||||
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
|
||||
WorkspaceID: src,
|
||||
RepoScopeMode: RepoScopeModeRepos,
|
||||
RepoScopeRepos: []RepoFilter{{Owner: "kdlbs", Name: "kandev"}},
|
||||
SavedPresets: []byte(`[{"id":"p1","kind":"pr","label":"Mine"}]`),
|
||||
DefaultQueryPresets: []byte(`{"pr":[],"issue":[]}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source: %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
|
||||
WorkspaceID: src,
|
||||
PR: &[]ActionPreset{{ID: "custom", Label: "Custom", PromptTemplate: "do {{url}}"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source action presets: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CopyWorkspaceSettingsToWorkspace(ctx, src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
if got.WorkspaceID != dst || got.RepoScopeMode != RepoScopeModeRepos {
|
||||
t.Errorf("copied settings identity/scope mismatch: %+v", got)
|
||||
}
|
||||
if len(got.RepoScopeRepos) != 1 || got.RepoScopeRepos[0].Owner != "kdlbs" ||
|
||||
got.RepoScopeRepos[0].Name != "kandev" {
|
||||
t.Errorf("repo scope not copied: %+v", got.RepoScopeRepos)
|
||||
}
|
||||
if string(got.SavedPresets) != `[{"id":"p1","kind":"pr","label":"Mine"}]` {
|
||||
t.Errorf("saved presets not copied: %s", got.SavedPresets)
|
||||
}
|
||||
if string(got.DefaultQueryPresets) != `{"pr":[],"issue":[]}` {
|
||||
t.Errorf("default query presets not copied: %s", got.DefaultQueryPresets)
|
||||
}
|
||||
|
||||
presets, err := svc.GetActionPresets(ctx, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("get copied action presets: %v", err)
|
||||
}
|
||||
if len(presets.PR) != 1 || presets.PR[0].ID != "custom" {
|
||||
t.Errorf("action presets not copied: %+v", presets.PR)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source relies on default presets (no stored row), the copy must
|
||||
// clear the target's customised presets so both fall back to the same defaults —
|
||||
// otherwise the copy silently leaves the target diverged from the source.
|
||||
func TestCopyWorkspaceSettingsToWorkspace_ClearsTargetPresetsWhenSourceHasNone(t *testing.T) {
|
||||
svc := newCopyTestService(t)
|
||||
ctx := context.Background()
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
|
||||
// Target has customised presets; source has none stored.
|
||||
if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{
|
||||
WorkspaceID: dst,
|
||||
PR: &[]ActionPreset{{ID: "target-custom", Label: "Target", PromptTemplate: "x {{url}}"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed target action presets: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.CopyWorkspaceSettingsToWorkspace(ctx, src, dst); err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
|
||||
defaults, err := svc.GetActionPresets(ctx, "ws-fresh")
|
||||
if err != nil {
|
||||
t.Fatalf("get default presets: %v", err)
|
||||
}
|
||||
got, err := svc.GetActionPresets(ctx, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("get target presets: %v", err)
|
||||
}
|
||||
if len(got.PR) != len(defaults.PR) {
|
||||
t.Errorf("target presets not reset to defaults: got %d, want %d", len(got.PR), len(defaults.PR))
|
||||
}
|
||||
for _, p := range got.PR {
|
||||
if p.ID == "target-custom" {
|
||||
t.Errorf("target retained its customised preset after copy from a source with none")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyWorkspaceSettingsToWorkspace_SameWorkspace(t *testing.T) {
|
||||
svc := newCopyTestService(t)
|
||||
if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) {
|
||||
t.Fatalf("expected ErrSameWorkspace, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyWorkspaceSettingsToWorkspace_MissingIDs(t *testing.T) {
|
||||
svc := newCopyTestService(t)
|
||||
if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "", "ws-dst"); !errors.Is(err, ErrWorkspaceSettingsValidation) {
|
||||
t.Fatalf("expected ErrWorkspaceSettingsValidation for empty source, got %v", err)
|
||||
}
|
||||
if _, err := svc.CopyWorkspaceSettingsToWorkspace(context.Background(), "ws-src", ""); !errors.Is(err, ErrWorkspaceSettingsValidation) {
|
||||
t.Fatalf("expected ErrWorkspaceSettingsValidation for empty destination, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
|
||||
// from.
|
||||
var ErrSameWorkspace = errors.New("jira: source and target workspaces are the same")
|
||||
|
||||
// ErrNothingToCopy is returned when the source workspace has no Jira config.
|
||||
var ErrNothingToCopy = errors.New("jira: source workspace has no configuration to copy")
|
||||
|
||||
// CopyConfigToWorkspace copies the Jira provider config and credential (token)
|
||||
// from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally out
|
||||
// of scope — only the connection settings and secret are duplicated.
|
||||
func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*JiraConfig, error) {
|
||||
sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceWorkspaceID == targetWorkspaceID {
|
||||
return nil, ErrSameWorkspace
|
||||
}
|
||||
cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source jira config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
secret, err := s.revealSecret(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source jira secret: %w", err)
|
||||
}
|
||||
// An empty Secret means "keep existing" in SetConfigForWorkspace, so copying
|
||||
// from a workspace without a stored token would leave the target on its old
|
||||
// credential paired with the copied site/email. Treat that as nothing to copy.
|
||||
if secret == "" {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
req := &SetConfigRequest{
|
||||
SiteURL: cfg.SiteURL,
|
||||
Email: cfg.Email,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DefaultProjectKey: cfg.DefaultProjectKey,
|
||||
Secret: secret,
|
||||
}
|
||||
// Mark the target unhealthy before the write so the UI doesn't briefly flash
|
||||
// a stale "connected" state from the target's previous credential while the
|
||||
// async probe kicked off by SetConfigForWorkspace validates the copied one.
|
||||
if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", time.Now().UTC()); err != nil {
|
||||
return nil, fmt.Errorf("reset target jira health: %w", err)
|
||||
}
|
||||
cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req)
|
||||
if err != nil {
|
||||
// The write failed, so the target's config is unchanged but we already
|
||||
// flipped its health row. Re-probe asynchronously (best effort) so the row
|
||||
// reflects the target's real state again rather than a spurious
|
||||
// "unhealthy", without delaying the error response by the probe timeout.
|
||||
// Detach from the request context, which may already be cancelled.
|
||||
go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID)
|
||||
return nil, err
|
||||
}
|
||||
return cfgOut, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// drainProbe waits for the async auth-health probe fired by a config write so
|
||||
// the test does not race the background DB write.
|
||||
func drainProbe(t *testing.T, f *svcFixture) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.probed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("async probe hook did not fire within 2s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_CopiesConfigAndSecret(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx := context.Background()
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
|
||||
if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{
|
||||
SiteURL: "https://acme.atlassian.net",
|
||||
Email: "u@example.com",
|
||||
AuthMethod: AuthMethodAPIToken,
|
||||
InstanceType: InstanceTypeCloud,
|
||||
DefaultProjectKey: "ENG",
|
||||
Secret: "tok-src",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source: %v", err)
|
||||
}
|
||||
drainProbe(t, f)
|
||||
|
||||
got, err := f.svc.CopyConfigToWorkspace(ctx, src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
drainProbe(t, f)
|
||||
|
||||
if got.SiteURL != "https://acme.atlassian.net" || got.Email != "u@example.com" ||
|
||||
got.InstanceType != InstanceTypeCloud || got.DefaultProjectKey != "ENG" {
|
||||
t.Errorf("copied config mismatch: %+v", got)
|
||||
}
|
||||
v, err := f.secrets.Reveal(ctx, SecretKeyForWorkspace(dst))
|
||||
if err != nil {
|
||||
t.Fatalf("reveal copied secret: %v", err)
|
||||
}
|
||||
if v != "tok-src" {
|
||||
t.Errorf("secret not copied: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) {
|
||||
t.Fatalf("expected ErrSameWorkspace, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) {
|
||||
t.Fatalf("expected ErrNothingToCopy, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.POST("/config", c.httpSetConfig)
|
||||
api.DELETE("/config", c.httpDeleteConfig)
|
||||
api.POST("/config/test", c.httpTestConfig)
|
||||
api.POST("/config/copy", c.httpCopyConfig)
|
||||
api.GET("/projects", c.httpListProjects)
|
||||
api.GET("/projects/:key/statuses", c.httpListProjectStatuses)
|
||||
api.GET("/tickets", c.httpSearchTickets)
|
||||
@@ -184,6 +185,42 @@ func (c *Controller) workspaceID(ctx *gin.Context) string {
|
||||
return strings.TrimSpace(ctx.Query("workspace_id"))
|
||||
}
|
||||
|
||||
// copyConfigRequest is the payload for the copy-config endpoint. The source
|
||||
// workspace comes from the workspace_id query param; the body carries the
|
||||
// target.
|
||||
type copyConfigRequest struct {
|
||||
TargetWorkspaceID string `json:"targetWorkspaceId"`
|
||||
}
|
||||
|
||||
func (c *Controller) httpCopyConfig(ctx *gin.Context) {
|
||||
sourceWorkspaceID := c.workspaceID(ctx)
|
||||
if sourceWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
|
||||
return
|
||||
}
|
||||
var req copyConfigRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID)
|
||||
if targetWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"})
|
||||
return
|
||||
}
|
||||
cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
ctx.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// --- Issue watch HTTP handlers ---
|
||||
|
||||
// httpListIssueWatches returns watches scoped to one workspace when
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package linear
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
|
||||
// from.
|
||||
var ErrSameWorkspace = errors.New("linear: source and target workspaces are the same")
|
||||
|
||||
// ErrNothingToCopy is returned when the source workspace has no Linear config.
|
||||
var ErrNothingToCopy = errors.New("linear: source workspace has no configuration to copy")
|
||||
|
||||
// CopyConfigToWorkspace copies the Linear provider config and credential (API
|
||||
// key) from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally
|
||||
// out of scope — only the connection settings and secret are duplicated.
|
||||
func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*LinearConfig, error) {
|
||||
sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceWorkspaceID == targetWorkspaceID {
|
||||
return nil, ErrSameWorkspace
|
||||
}
|
||||
cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source linear config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
secret, err := s.revealSecret(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source linear secret: %w", err)
|
||||
}
|
||||
// An empty Secret means "keep existing" in SetConfigForWorkspace, so copying
|
||||
// from a workspace without a stored API key would leave the target on its old
|
||||
// credential paired with the copied settings. Treat that as nothing to copy.
|
||||
if secret == "" {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
req := &SetConfigRequest{
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
DefaultTeamKey: cfg.DefaultTeamKey,
|
||||
Secret: secret,
|
||||
}
|
||||
// Mark the target unhealthy before the write so the UI doesn't briefly flash
|
||||
// a stale "connected" state (from a previously connected org) while the async
|
||||
// probe kicked off by SetConfigForWorkspace validates the copied credential.
|
||||
if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", "", time.Now().UTC()); err != nil {
|
||||
return nil, fmt.Errorf("reset target linear health: %w", err)
|
||||
}
|
||||
cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req)
|
||||
if err != nil {
|
||||
// The write failed, so the target's config is unchanged but we already
|
||||
// flipped its health row. Re-probe asynchronously (best effort) so the row
|
||||
// reflects the target's real state again rather than a spurious
|
||||
// "unhealthy", without delaying the error response by the probe timeout.
|
||||
// Detach from the request context, which may already be cancelled.
|
||||
go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID)
|
||||
return nil, err
|
||||
}
|
||||
return cfgOut, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package linear
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// drainProbe waits for the async auth-health probe fired by a config write.
|
||||
func drainProbe(t *testing.T, f *svcFixture) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.probed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("async probe hook did not fire within 2s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_CopiesConfigAndSecret(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx := context.Background()
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
|
||||
if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{
|
||||
AuthMethod: AuthMethodAPIKey,
|
||||
DefaultTeamKey: "ENG",
|
||||
Secret: "lin-src",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source: %v", err)
|
||||
}
|
||||
drainProbe(t, f)
|
||||
|
||||
got, err := f.svc.CopyConfigToWorkspace(ctx, src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
drainProbe(t, f)
|
||||
|
||||
if got.DefaultTeamKey != "ENG" || got.AuthMethod != AuthMethodAPIKey {
|
||||
t.Errorf("copied config mismatch: %+v", got)
|
||||
}
|
||||
v, err := f.secrets.Reveal(ctx, SecretKeyForWorkspace(dst))
|
||||
if err != nil {
|
||||
t.Fatalf("reveal copied secret: %v", err)
|
||||
}
|
||||
if v != "lin-src" {
|
||||
t.Errorf("secret not copied: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) {
|
||||
t.Fatalf("expected ErrSameWorkspace, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) {
|
||||
t.Fatalf("expected ErrNothingToCopy, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyConfigToWorkspace_MissingSecret guards the empty-secret boundary: a
|
||||
// source config row whose stored secret is empty must fail with ErrNothingToCopy
|
||||
// rather than copy an empty secret (which SetConfigForWorkspace treats as
|
||||
// "preserve existing", silently leaving the target on its old credential).
|
||||
func TestCopyConfigToWorkspace_MissingSecret(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx := context.Background()
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
// Seed a config row directly and an empty stored secret so revealSecret
|
||||
// returns ("", nil) instead of a not-found error.
|
||||
if err := f.store.UpsertConfigForWorkspace(ctx, src, &LinearConfig{
|
||||
AuthMethod: AuthMethodAPIKey,
|
||||
DefaultTeamKey: "ENG",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source config: %v", err)
|
||||
}
|
||||
// Store empty values for both the workspace-scoped and legacy keys so
|
||||
// revealSecret returns ("", nil) rather than a not-found error.
|
||||
if err := f.secrets.Set(ctx, SecretKeyForWorkspace(src), "Linear API key", ""); err != nil {
|
||||
t.Fatalf("seed empty secret: %v", err)
|
||||
}
|
||||
if err := f.secrets.Set(ctx, SecretKey, "Linear API key", ""); err != nil {
|
||||
t.Fatalf("seed empty legacy secret: %v", err)
|
||||
}
|
||||
if _, err := f.svc.CopyConfigToWorkspace(ctx, src, dst); !errors.Is(err, ErrNothingToCopy) {
|
||||
t.Fatalf("expected ErrNothingToCopy for empty secret, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.POST("/config", c.httpSetConfig)
|
||||
api.DELETE("/config", c.httpDeleteConfig)
|
||||
api.POST("/config/test", c.httpTestConfig)
|
||||
api.POST("/config/copy", c.httpCopyConfig)
|
||||
api.GET("/teams", c.httpListTeams)
|
||||
api.GET("/states", c.httpListStates)
|
||||
api.GET("/labels", c.httpListLabels)
|
||||
@@ -210,6 +211,42 @@ func (c *Controller) workspaceID(ctx *gin.Context) string {
|
||||
return strings.TrimSpace(ctx.Query("workspace_id"))
|
||||
}
|
||||
|
||||
// copyConfigRequest is the payload for the copy-config endpoint. The source
|
||||
// workspace comes from the workspace_id query param; the body carries the
|
||||
// target.
|
||||
type copyConfigRequest struct {
|
||||
TargetWorkspaceID string `json:"targetWorkspaceId"`
|
||||
}
|
||||
|
||||
func (c *Controller) httpCopyConfig(ctx *gin.Context) {
|
||||
sourceWorkspaceID := c.workspaceID(ctx)
|
||||
if sourceWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
|
||||
return
|
||||
}
|
||||
var req copyConfigRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID)
|
||||
if targetWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"})
|
||||
return
|
||||
}
|
||||
cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
ctx.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// parseSearchNumericFilters parses priority + estimate query params onto the
|
||||
// filter and returns a 400-worthy error when any are malformed. Invalid input
|
||||
// is rejected rather than silently dropped so callers don't accidentally widen
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package sentry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
|
||||
// from.
|
||||
var ErrSameWorkspace = errors.New("sentry: source and target workspaces are the same")
|
||||
|
||||
// ErrNothingToCopy is returned when the source workspace has no Sentry config.
|
||||
var ErrNothingToCopy = errors.New("sentry: source workspace has no configuration to copy")
|
||||
|
||||
// CopyConfigToWorkspace copies the Sentry provider config and credential (auth
|
||||
// token) from sourceWorkspaceID to targetWorkspaceID. Watchers are
|
||||
// intentionally out of scope — only the connection settings and secret are
|
||||
// duplicated.
|
||||
func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*SentryConfig, error) {
|
||||
sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceWorkspaceID == targetWorkspaceID {
|
||||
return nil, ErrSameWorkspace
|
||||
}
|
||||
cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source sentry config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
secret, err := s.revealSecret(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source sentry secret: %w", err)
|
||||
}
|
||||
// An empty Secret means "keep existing" in SetConfigForWorkspace, so copying
|
||||
// from a workspace without a stored token would leave the target on its old
|
||||
// credential paired with the copied settings. Treat that as nothing to copy.
|
||||
if secret == "" {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
req := &SetConfigRequest{
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
URL: cfg.URL,
|
||||
Secret: secret,
|
||||
}
|
||||
// Mark the target unhealthy before the write so the UI doesn't briefly flash
|
||||
// a stale "connected" state from the target's previous credential while the
|
||||
// async probe kicked off by SetConfigForWorkspace validates the copied one.
|
||||
if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", time.Now().UTC()); err != nil {
|
||||
return nil, fmt.Errorf("reset target sentry health: %w", err)
|
||||
}
|
||||
cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req)
|
||||
if err != nil {
|
||||
// The write failed, so the target's config is unchanged but we already
|
||||
// flipped its health row. Re-probe asynchronously (best effort) so the row
|
||||
// reflects the target's real state again rather than a spurious
|
||||
// "unhealthy", without delaying the error response by the probe timeout.
|
||||
// Detach from the request context, which may already be cancelled.
|
||||
go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID)
|
||||
return nil, err
|
||||
}
|
||||
return cfgOut, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package sentry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// drainProbe waits for the async auth-health probe fired by a config write.
|
||||
func drainProbe(t *testing.T, f *svcFixture) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-f.probed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("async probe hook did not fire within 2s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_CopiesConfigAndSecret(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
ctx := context.Background()
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
|
||||
if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{
|
||||
AuthMethod: AuthMethodAuthToken,
|
||||
URL: "https://sentry.example.com",
|
||||
Secret: "sen-src",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source: %v", err)
|
||||
}
|
||||
drainProbe(t, f)
|
||||
|
||||
got, err := f.svc.CopyConfigToWorkspace(ctx, src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
drainProbe(t, f)
|
||||
|
||||
if got.URL != "https://sentry.example.com" || got.AuthMethod != AuthMethodAuthToken {
|
||||
t.Errorf("copied config mismatch: %+v", got)
|
||||
}
|
||||
v, err := f.secrets.Reveal(ctx, SecretKeyForWorkspace(dst))
|
||||
if err != nil {
|
||||
t.Fatalf("reveal copied secret: %v", err)
|
||||
}
|
||||
if v != "sen-src" {
|
||||
t.Errorf("secret not copied: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) {
|
||||
t.Fatalf("expected ErrSameWorkspace, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) {
|
||||
f := newSvcFixture(t)
|
||||
if _, err := f.svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) {
|
||||
t.Fatalf("expected ErrNothingToCopy, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.PUT("/config", c.httpSetConfig)
|
||||
api.DELETE("/config", c.httpDeleteConfig)
|
||||
api.POST("/config/test", c.httpTestConfig)
|
||||
api.POST("/config/copy", c.httpCopyConfig)
|
||||
api.GET("/organizations", c.httpListOrganizations)
|
||||
api.GET("/projects", c.httpListProjects)
|
||||
api.GET("/issues", c.httpSearchIssues)
|
||||
@@ -160,6 +161,42 @@ func (c *Controller) workspaceID(ctx *gin.Context) string {
|
||||
return strings.TrimSpace(ctx.Query("workspace_id"))
|
||||
}
|
||||
|
||||
// copyConfigRequest is the payload for the copy-config endpoint. The source
|
||||
// workspace comes from the workspace_id query param; the body carries the
|
||||
// target.
|
||||
type copyConfigRequest struct {
|
||||
TargetWorkspaceID string `json:"targetWorkspaceId"`
|
||||
}
|
||||
|
||||
func (c *Controller) httpCopyConfig(ctx *gin.Context) {
|
||||
sourceWorkspaceID := c.workspaceID(ctx)
|
||||
if sourceWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
|
||||
return
|
||||
}
|
||||
var req copyConfigRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID)
|
||||
if targetWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"})
|
||||
return
|
||||
}
|
||||
cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
ctx.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// errCodeSentryNotConfigured is the wire-level code surfaced to the UI when
|
||||
// Sentry has no saved credentials.
|
||||
const errCodeSentryNotConfigured = "SENTRY_NOT_CONFIGURED"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
|
||||
// from — a no-op the caller should surface as a client error rather than
|
||||
// silently succeeding.
|
||||
var ErrSameWorkspace = errors.New("slack: source and target workspaces are the same")
|
||||
|
||||
// ErrNothingToCopy is returned when the source workspace has no Slack config to
|
||||
// copy.
|
||||
var ErrNothingToCopy = errors.New("slack: source workspace has no configuration to copy")
|
||||
|
||||
// CopyConfigToWorkspace copies the Slack provider config and credentials
|
||||
// (token + cookie) from sourceWorkspaceID to targetWorkspaceID. Watchers and
|
||||
// automations are intentionally out of scope — only the connection settings and
|
||||
// secrets are duplicated. The target's health probe re-runs so runtime fields
|
||||
// (team/user id) repopulate from the copied credentials.
|
||||
func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*SlackConfig, error) {
|
||||
sourceWorkspaceID, err := s.normalizeWorkspaceID(sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetWorkspaceID, err = s.normalizeWorkspaceID(targetWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceWorkspaceID == targetWorkspaceID {
|
||||
return nil, ErrSameWorkspace
|
||||
}
|
||||
cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source slack config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
token, cookie, err := s.revealSecrets(ctx, sourceWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Empty Token/Cookie mean "preserve existing" in SetConfigForWorkspace, so a
|
||||
// source without stored secrets would silently leave the target on its old
|
||||
// credentials. Treat a missing source secret as nothing to copy instead.
|
||||
if token == "" || cookie == "" {
|
||||
return nil, ErrNothingToCopy
|
||||
}
|
||||
req := &SetConfigRequest{
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
CommandPrefix: cfg.CommandPrefix,
|
||||
UtilityAgentID: cfg.UtilityAgentID,
|
||||
PollIntervalSeconds: cfg.PollIntervalSeconds,
|
||||
Token: token,
|
||||
Cookie: cookie,
|
||||
}
|
||||
// Mark the target unhealthy before the write so the UI doesn't briefly flash
|
||||
// a stale "connected" state (old team/user id) from the target's previous
|
||||
// connection while the async probe validates the copied credentials. The
|
||||
// probe repopulates team/user id from the new credentials.
|
||||
if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", "", "", time.Now().UTC()); err != nil {
|
||||
return nil, fmt.Errorf("reset target slack health: %w", err)
|
||||
}
|
||||
cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req)
|
||||
if err != nil {
|
||||
// The write failed, so the target's config is unchanged but we already
|
||||
// flipped its health row. Re-probe asynchronously (best effort) so the row
|
||||
// reflects the target's real state again rather than a spurious
|
||||
// "unhealthy", without delaying the error response by the probe timeout.
|
||||
// Detach from the request context, which may already be cancelled.
|
||||
go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID)
|
||||
return nil, err
|
||||
}
|
||||
return cfgOut, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// fakeSecrets is an in-memory SecretStore for copy tests.
|
||||
type fakeSecrets struct {
|
||||
mu sync.Mutex
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func newFakeSecrets() *fakeSecrets { return &fakeSecrets{values: map[string]string{}} }
|
||||
|
||||
func (f *fakeSecrets) Reveal(_ context.Context, id string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.values[id], nil
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) Set(_ context.Context, id, _, value string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.values[id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) Delete(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.values, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) Exists(_ context.Context, id string) (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.values[id]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) get(id string) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.values[id]
|
||||
}
|
||||
|
||||
func newCopyTestService(t *testing.T, secrets SecretStore) *Service {
|
||||
t.Helper()
|
||||
store := newTestStore(t)
|
||||
// A fake client keeps the async health probe from doing real IO.
|
||||
factory := func(_ *SlackConfig, _, _ string) Client { return &fakeClient{} }
|
||||
return NewService(store, secrets, nil, factory, logger.Default())
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_CopiesConfigAndSecrets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
secrets := newFakeSecrets()
|
||||
svc := newCopyTestService(t, secrets)
|
||||
// Block on the async probe so the test doesn't race the background write.
|
||||
done := make(chan struct{}, 4)
|
||||
svc.SetProbeHook(func() { done <- struct{}{} })
|
||||
|
||||
const src, dst = "ws-src", "ws-dst"
|
||||
if _, err := svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{
|
||||
AuthMethod: AuthMethodCookie,
|
||||
CommandPrefix: "!go",
|
||||
UtilityAgentID: "ua-1",
|
||||
PollIntervalSeconds: 45,
|
||||
Token: "xoxc-token",
|
||||
Cookie: "d-cookie",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source: %v", err)
|
||||
}
|
||||
<-done
|
||||
|
||||
got, err := svc.CopyConfigToWorkspace(ctx, src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
<-done
|
||||
|
||||
if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!go" || got.PollIntervalSeconds != 45 {
|
||||
t.Errorf("copied config mismatch: %+v", got)
|
||||
}
|
||||
if v := secrets.get(SecretKeyForToken(dst)); v != "xoxc-token" {
|
||||
t.Errorf("token not copied: %q", v)
|
||||
}
|
||||
if v := secrets.get(SecretKeyForCookie(dst)); v != "d-cookie" {
|
||||
t.Errorf("cookie not copied: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_SameWorkspace(t *testing.T) {
|
||||
svc := newCopyTestService(t, newFakeSecrets())
|
||||
if _, err := svc.CopyConfigToWorkspace(context.Background(), "ws-1", "ws-1"); !errors.Is(err, ErrSameWorkspace) {
|
||||
t.Fatalf("expected ErrSameWorkspace, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyConfigToWorkspace_NothingToCopy(t *testing.T) {
|
||||
svc := newCopyTestService(t, newFakeSecrets())
|
||||
if _, err := svc.CopyConfigToWorkspace(context.Background(), "ws-empty", "ws-dst"); !errors.Is(err, ErrNothingToCopy) {
|
||||
t.Fatalf("expected ErrNothingToCopy, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyConfigToWorkspace_MissingSecret guards against silently leaving the
|
||||
// target on its previous credentials: a source config row without stored
|
||||
// token/cookie must fail rather than copy an empty secret (which
|
||||
// SetConfigForWorkspace would treat as "preserve existing").
|
||||
func TestCopyConfigToWorkspace_MissingSecret(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newCopyTestService(t, newFakeSecrets())
|
||||
// Seed a config row directly, without storing any token/cookie secret.
|
||||
if err := svc.store.UpsertConfigForWorkspace(ctx, "ws-src", &SlackConfig{
|
||||
AuthMethod: AuthMethodCookie,
|
||||
UtilityAgentID: "ua-1",
|
||||
PollIntervalSeconds: 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed source config: %v", err)
|
||||
}
|
||||
if _, err := svc.CopyConfigToWorkspace(ctx, "ws-src", "ws-dst"); !errors.Is(err, ErrNothingToCopy) {
|
||||
t.Fatalf("expected ErrNothingToCopy for missing secret, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,43 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api.POST("/config", c.httpSetConfig)
|
||||
api.DELETE("/config", c.httpDeleteConfig)
|
||||
api.POST("/config/test", c.httpTestConfig)
|
||||
api.POST("/config/copy", c.httpCopyConfig)
|
||||
}
|
||||
|
||||
// copyConfigRequest is the payload for the copy-config endpoint. The source
|
||||
// workspace is taken from the workspace_id query param (like every other
|
||||
// handler here); the body carries the target.
|
||||
type copyConfigRequest struct {
|
||||
TargetWorkspaceID string `json:"targetWorkspaceId"`
|
||||
}
|
||||
|
||||
func (c *Controller) httpCopyConfig(ctx *gin.Context) {
|
||||
sourceWorkspaceID := c.workspaceID(ctx)
|
||||
if sourceWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
|
||||
return
|
||||
}
|
||||
var req copyConfigRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
targetWorkspaceID := strings.TrimSpace(req.TargetWorkspaceID)
|
||||
if targetWorkspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "targetWorkspaceId required"})
|
||||
return
|
||||
}
|
||||
cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), sourceWorkspaceID, targetWorkspaceID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrSameWorkspace), errors.Is(err, ErrNothingToCopy), errors.Is(err, ErrInvalidConfig):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
ctx.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// --- HTTP handlers ---
|
||||
|
||||
Reference in New Issue
Block a user