Files
kandev/apps/backend/internal/secrets/copy_move_test.go
T

336 lines
14 KiB
Go

package secrets
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/kandev/kandev/internal/auth/authn"
)
// testCopyMoveService builds a service over the user-visible SQLite store with
// an authorizer and existence checker that allow the listed workspaces.
func testCopyMoveService(t *testing.T, allowed map[string]bool) *Service {
t.Helper()
store := newTestSQLiteStore(t)
svc := NewService(NewUserVisibleStore(store), nil)
svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error {
if allowed[workspaceID] {
return nil
}
return ErrWorkspaceAccessDenied
})
svc.SetWorkspaceExistenceChecker(func(_ context.Context, workspaceID string) error {
if allowed[workspaceID] {
return nil
}
return ErrWorkspaceAccessDenied
})
return svc
}
// decodeCopyRequest unmarshals a JSON copy/move request body for testing.
func decodeCopyRequest(t *testing.T, body string) *CopySecretRequest {
t.Helper()
var req CopySecretRequest
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
return &req
}
// TestServiceCopy_GlobalToWorkspaceUsesSourceNameWhenOmitted verifies a global-to-workspace copy with no explicit name keeps the source name and value and retains the source.
func TestServiceCopy_GlobalToWorkspaceUsesSourceNameWhenOmitted(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
if _, err := svc.Create(ctx, &CreateSecretRequest{Name: "API Key", Value: "v1"}); err != nil {
t.Fatalf("create: %v", err)
}
item, err := svc.Copy(ctx, globalID(t, svc, "API Key"), "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a"}`))
if err != nil {
t.Fatalf("Copy: %v", err)
}
if item.Scope != ScopeWorkspace || item.WorkspaceID != "workspace-a" || item.Name != "API Key" {
t.Fatalf("item = %+v", item)
}
if item.CreatedAt.IsZero() || item.UpdatedAt.IsZero() {
t.Fatalf("item timestamps zero: %+v", item)
}
value, err := svc.RevealForWorkspace(ctx, item.ID, "workspace-a")
if err != nil {
t.Fatalf("reveal: %v", err)
}
if value != "v1" {
t.Fatalf("value = %q, want v1", value)
}
// Source retained.
global, err := svc.List(ctx)
if err != nil || len(global) != 1 {
t.Fatalf("global list after copy = %+v, %v", global, err)
}
}
// TestServiceCopy_WorkspaceToGlobalWithExplicitName verifies a workspace-to-global copy with an explicit name keeps the source intact.
func TestServiceCopy_WorkspaceToGlobalWithExplicitName(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
w := mustCreateViaService(t, svc, "ws", "v2", ScopeWorkspace, "workspace-a")
item, err := svc.Copy(ctx, w.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"global","name":"renamed"}`))
if err != nil {
t.Fatalf("Copy: %v", err)
}
if item.Scope != ScopeGlobal || item.Name != "renamed" {
t.Fatalf("item = %+v", item)
}
if _, err := svc.GetWorkspaceSecret(ctx, w.ID, "workspace-a"); err != nil {
t.Fatalf("source lost after copy: %v", err)
}
}
// TestServiceCopy_WorkspaceToWorkspace verifies a workspace-to-workspace copy lands in the target workspace.
func TestServiceCopy_WorkspaceToWorkspace(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true, "workspace-b": true})
ctx := context.Background()
w := mustCreateViaService(t, svc, "ws", "v3", ScopeWorkspace, "workspace-a")
item, err := svc.Copy(ctx, w.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-b","name":"to-b"}`))
if err != nil {
t.Fatalf("Copy: %v", err)
}
if item.WorkspaceID != "workspace-b" {
t.Fatalf("item workspace = %q", item.WorkspaceID)
}
}
// TestServiceMove_WorkspaceToGlobalRemovesSource verifies moving a workspace secret to global removes the source.
func TestServiceMove_WorkspaceToGlobalRemovesSource(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
w := mustCreateViaService(t, svc, "ws", "v4", ScopeWorkspace, "workspace-a")
item, err := svc.Move(ctx, w.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"global","name":"moved"}`))
if err != nil {
t.Fatalf("Move: %v", err)
}
if item.Scope != ScopeGlobal {
t.Fatalf("item scope = %q", item.Scope)
}
if _, err := svc.GetWorkspaceSecret(ctx, w.ID, "workspace-a"); !errors.Is(err, ErrNotFound) {
t.Fatalf("source still present after move: %v", err)
}
}
// TestServiceMove_GlobalToWorkspaceRemovesSource verifies moving a global secret into a workspace removes the source.
func TestServiceMove_GlobalToWorkspaceRemovesSource(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
g := mustCreateViaService(t, svc, "g", "v5", ScopeGlobal, "")
item, err := svc.Move(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a"}`))
if err != nil {
t.Fatalf("Move: %v", err)
}
if item.WorkspaceID != "workspace-a" {
t.Fatalf("item workspace = %q", item.WorkspaceID)
}
if _, err := svc.Get(ctx, g.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("source still present after move: %v", err)
}
}
// TestServiceCopy_NamePresenceSemantics verifies null, empty, whitespace, and over-long target names are rejected as ErrSecretValidation.
func TestServiceCopy_NamePresenceSemantics(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
g := mustCreateViaService(t, svc, "API Key", "v", ScopeGlobal, "")
t.Run("null is a validation error", func(t *testing.T) {
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a","name":null}`))
if !errors.Is(err, ErrSecretValidation) {
t.Fatalf("err = %v, want ErrSecretValidation", err)
}
})
t.Run("empty string is a validation error", func(t *testing.T) {
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a","name":""}`))
if !errors.Is(err, ErrSecretValidation) {
t.Fatalf("err = %v, want ErrSecretValidation", err)
}
})
t.Run("whitespace is a validation error", func(t *testing.T) {
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a","name":" "}`))
if !errors.Is(err, ErrSecretValidation) {
t.Fatalf("err = %v, want ErrSecretValidation", err)
}
})
t.Run("over 100 UTF-8 bytes is a validation error", func(t *testing.T) {
long := ""
for len(long) <= 100 {
long += "é" // 2 bytes per code point
}
req := decodeCopyRequest(t, fmt.Sprintf(`{"target_scope":"workspace","target_workspace_id":"workspace-a","name":%q}`, long))
_, err := svc.Copy(ctx, g.ID, "", req)
if !errors.Is(err, ErrSecretValidation) {
t.Fatalf("err = %v, want ErrSecretValidation", err)
}
})
}
// TestServiceCopy_SameScopeRejected verifies copying within the same scope returns ErrSecretValidation.
func TestServiceCopy_SameScopeRejected(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
g := mustCreateViaService(t, svc, "g", "v", ScopeGlobal, "")
w := mustCreateViaService(t, svc, "w", "v", ScopeWorkspace, "workspace-a")
if _, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"global"}`)); !errors.Is(err, ErrSecretValidation) {
t.Fatalf("global->global err = %v, want ErrSecretValidation", err)
}
if _, err := svc.Copy(ctx, w.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a"}`)); !errors.Is(err, ErrSecretValidation) {
t.Fatalf("workspace A->A err = %v, want ErrSecretValidation", err)
}
}
// TestServiceCopy_NameConflict verifies a target-name collision in the destination scope returns ErrSecretNameConflict.
func TestServiceCopy_NameConflict(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
// "taken" lives in the workspace target scope.
mustCreateViaService(t, svc, "taken", "other", ScopeWorkspace, "workspace-a")
g := mustCreateViaService(t, svc, "g", "v", ScopeGlobal, "")
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a","name":"taken"}`))
if !errors.Is(err, ErrSecretNameConflict) {
t.Fatalf("err = %v, want ErrSecretNameConflict", err)
}
}
// TestServiceCopy_MissingSourceIsNotFound verifies an unknown source ID returns ErrNotFound.
func TestServiceCopy_MissingSourceIsNotFound(t *testing.T) {
svc := testCopyMoveService(t, nil)
_, err := svc.Copy(context.Background(), "missing", "", decodeCopyRequest(t, `{"target_scope":"global"}`))
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
// TestServiceCopy_UnauthorizedSourceIsDenied verifies revoking source-workspace access makes Copy fail with ErrWorkspaceAccessDenied.
func TestServiceCopy_UnauthorizedSourceIsDenied(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
ctx := context.Background()
w := mustCreateViaService(t, svc, "w", "v", ScopeWorkspace, "workspace-a")
// Revoke access to the source workspace after seeding the secret.
svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return ErrWorkspaceAccessDenied })
svc.SetWorkspaceExistenceChecker(func(context.Context, string) error { return ErrWorkspaceAccessDenied })
_, err := svc.Copy(ctx, w.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"global"}`))
if !errors.Is(err, ErrWorkspaceAccessDenied) {
t.Fatalf("err = %v, want ErrWorkspaceAccessDenied", err)
}
}
// TestServiceCopy_UnauthorizedDestinationIsDenied verifies an unallowed destination workspace returns ErrWorkspaceAccessDenied.
func TestServiceCopy_UnauthorizedDestinationIsDenied(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true}) // workspace-b not allowed
ctx := context.Background()
g := mustCreateViaService(t, svc, "g", "v", ScopeGlobal, "")
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-b"}`))
if !errors.Is(err, ErrWorkspaceAccessDenied) {
t.Fatalf("err = %v, want ErrWorkspaceAccessDenied", err)
}
}
// TestServiceCopy_NilExistenceCheckerFailsClosed verifies a nil existence checker denies workspace targets (fails closed).
func TestServiceCopy_NilExistenceCheckerFailsClosed(t *testing.T) {
store := newTestSQLiteStore(t)
svc := NewService(NewUserVisibleStore(store), nil)
svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return nil })
ctx := context.Background()
g := mustCreateViaService(t, svc, "g", "v", ScopeGlobal, "")
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"any"}`))
if !errors.Is(err, ErrWorkspaceAccessDenied) {
t.Fatalf("err = %v, want ErrWorkspaceAccessDenied (nil checker fails closed)", err)
}
}
// TestServiceCopy_RawAuthorizerErrorPassesThroughUnclassified verifies a raw authorizer error is returned as-is rather than mapped to a sentinel.
func TestServiceCopy_RawAuthorizerErrorPassesThroughUnclassified(t *testing.T) {
store := newTestSQLiteStore(t)
svc := NewService(NewUserVisibleStore(store), nil)
rawErr := errors.New("database unreachable")
svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return rawErr })
svc.SetWorkspaceExistenceChecker(func(context.Context, string) error { return nil })
ctx := context.Background()
g := mustCreateViaService(t, svc, "g", "v", ScopeGlobal, "")
_, err := svc.Copy(ctx, g.ID, "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a"}`))
if !errors.Is(err, rawErr) {
t.Fatalf("err = %v, want the raw authorizer error (unclassified)", err)
}
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrSecretValidation) || errors.Is(err, ErrWorkspaceAccessDenied) {
t.Fatalf("raw error misclassified as a sentinel: %v", err)
}
}
// TestServiceCopy_InternalSourceIDIsNotFound verifies internal (backend-owned) source IDs are rejected as ErrNotFound.
func TestServiceCopy_InternalSourceIDIsNotFound(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
_, err := svc.Copy(context.Background(), "github:user:workspace:user:access", "", decodeCopyRequest(t, `{"target_scope":"workspace","target_workspace_id":"workspace-a"}`))
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
// TestServiceMove_CrossUserOwnershipEnforced verifies a user cannot move another user's workspace secret (ErrNotFound) while the owner can.
func TestServiceMove_CrossUserOwnershipEnforced(t *testing.T) {
svc := testCopyMoveService(t, map[string]bool{"workspace-a": true})
alice := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-a"})
bob := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-b"})
// Alice creates a workspace secret.
created, err := svc.Create(alice, &CreateSecretRequest{Name: "alice-ws", Value: "v", Scope: ScopeWorkspace, WorkspaceID: "workspace-a"})
if err != nil {
t.Fatalf("create as alice: %v", err)
}
// Bob cannot move it (store-level per-user scoping).
if _, err := svc.Move(bob, created.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"global"}`)); !errors.Is(err, ErrNotFound) {
t.Fatalf("bob move err = %v, want ErrNotFound", err)
}
// Alice can.
if _, err := svc.Move(alice, created.ID, "workspace-a", decodeCopyRequest(t, `{"target_scope":"global","name":"alice-global"}`)); err != nil {
t.Fatalf("alice move: %v", err)
}
}
// mustCreateViaService is a small helper that returns the created item.
func mustCreateViaService(t *testing.T, svc *Service, name, value string, scope SecretScope, workspaceID string) *SecretListItem {
t.Helper()
item, err := svc.Create(context.Background(), &CreateSecretRequest{Name: name, Value: value, Scope: scope, WorkspaceID: workspaceID})
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
return item
}
// globalID resolves a global secret's ID by name via List.
func globalID(t *testing.T, svc *Service, name string) string {
t.Helper()
items, err := svc.List(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
for _, item := range items {
if item.Name == name {
return item.ID
}
}
t.Fatalf("no global secret named %s", name)
return ""
}