From 0237c78d5393f3a0d07339c33874147e3ef98707 Mon Sep 17 00:00:00 2001 From: jedon da hyeon Date: Thu, 26 Mar 2026 17:56:59 +0900 Subject: [PATCH] feat: add Secrets Manager browser (M3.9) - Add SecretsManagerClientAPI interface and client to AwsRepository - Implement ListSecrets() and GetSecretDetail() with JSON key/value parsing - Register ServiceSecretsManager and FeatureSecretsBrowser in domain catalog - Add screenSecretList and screenSecretDetail TUI screens with filter support - Add Secret and SecretDetail models with DisplayTitle() and FilterText() - Add mock-based tests for list, get, and model methods --- PLAN.md | 4 + go.mod | 1 + go.sum | 2 + internal/app/app.go | 239 ++++++++++++++++++ internal/app/messages.go | 8 + internal/domain/catalog.go | 9 + internal/domain/model.go | 18 +- internal/services/aws/repository.go | 42 +-- internal/services/aws/secretsmanager.go | 57 +++++ internal/services/aws/secretsmanager_model.go | 34 +++ internal/services/aws/secretsmanager_test.go | 208 +++++++++++++++ 11 files changed, 599 insertions(+), 23 deletions(-) create mode 100644 internal/services/aws/secretsmanager.go create mode 100644 internal/services/aws/secretsmanager_model.go create mode 100644 internal/services/aws/secretsmanager_test.go diff --git a/PLAN.md b/PLAN.md index dbf9965..8affb22 100644 --- a/PLAN.md +++ b/PLAN.md @@ -161,6 +161,10 @@ unic/ - Graph rendering: Bubbletea viewport with braille/block characters - Multiple metrics overlay on single chart +**M3.9 — Secrets Manager** +- List secrets +- Drill into secret detail: name, key/value pairs, encryption key (KMS key ID) + --- ### M4 — Polish & Release diff --git a/go.mod b/go.mod index 4e6d670..b42321f 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/smithy-go v1.24.2 // indirect diff --git a/go.sum b/go.sum index d80d0df..1a1ca44 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 h1:H/ZYZ6QR4EXJAYElI5xkIM/yCz+ github.com/aws/aws-sdk-go-v2/service/rds v1.116.3/go.mod h1:QbXW4coAMakHQhf1qhE0eVVCen9gwB/Kvn+HHHKhpGY= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 h1:64aYPyHg3RjLvnMMSYQSg7aP+r1WRCPIS9SP9KfHjWg= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4/go.mod h1:bPSPzWTn9LSX6e0KPp4LlPoaspouZdKAlIdSMdhBBrs= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 h1:9aZbO86sraeCIHHCpZhxwN9tnVy9POkSKzi4/TpT54A= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4/go.mod h1:cxiXDhEzIq7Xx1BtmC4lGBK3SwAZ79+EUWiKawYHo14= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 h1:bBoWhx8lsFLTXintRX64ZBXcmFZbGqUmaPUrjXECqIc= diff --git a/internal/app/app.go b/internal/app/app.go index 97e8a13..c173ea3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,6 +6,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "unic/internal/config" "unic/internal/domain" @@ -28,6 +29,8 @@ const ( screenRoute53ZoneList screenRoute53RecordList screenRoute53RecordDetail + screenSecretList + screenSecretDetail screenContextPicker screenContextAdd screenLoading @@ -98,6 +101,14 @@ type Model struct { route53RecordFilterActive bool selectedRoute53Record *awsservice.DNSRecord + // Secrets Manager browser state + secrets []awsservice.Secret + filteredSecrets []awsservice.Secret + secretIdx int + secretFilter string + secretFilterActive bool + selectedSecret *awsservice.SecretDetail + // Context picker configPath string ctxList []config.ContextInfo @@ -217,6 +228,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screen = screenRDSList return m, nil + case secretsLoadedMsg: + m.secrets = msg.secrets + m.filteredSecrets = msg.secrets + m.secretIdx = 0 + m.screen = screenSecretList + return m, nil + + case secretDetailLoadedMsg: + m.selectedSecret = msg.detail + m.screen = screenSecretDetail + return m, nil + case rdsActionDoneMsg: if msg.err != nil { m.errMsg = msg.err.Error() @@ -338,6 +361,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateRoute53RecordList(msg) case screenRoute53RecordDetail: return m.updateRoute53RecordDetail(msg) + case screenSecretList: + return m.updateSecretList(msg) + case screenSecretDetail: + return m.updateSecretDetail(msg) case screenContextPicker: return m.updateContextPicker(msg) case screenContextAdd: @@ -404,6 +431,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case domain.FeatureRoute53Browser: m.screen = screenLoading return m, m.loadRoute53Zones() + case domain.FeatureSecretsBrowser: + m.screen = screenLoading + return m, m.loadSecrets() } } } @@ -453,6 +483,10 @@ func (m Model) View() string { v = m.viewRoute53RecordList() case screenRoute53RecordDetail: v = m.viewRoute53RecordDetail() + case screenSecretList: + v = m.viewSecretList() + case screenSecretDetail: + v = m.viewSecretDetail() case screenContextPicker: v = m.viewContextPicker() case screenContextAdd: @@ -561,3 +595,208 @@ func (m Model) viewFeatureList() string { b.WriteString(dimStyle.Render("↑/↓: navigate • enter: select • esc: back")) return b.String() } +func (m Model) loadSecrets() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + secrets, err := repo.ListSecrets(ctx) + if err != nil { + return errMsg{err: err} + } + if len(secrets) == 0 { + return errMsg{err: fmt.Errorf("no secrets found")} + } + return secretsLoadedMsg{secrets: secrets} + } +} + +func (m Model) loadSecretDetail(name string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + } + detail, err := repo.GetSecretDetail(ctx, name) + if err != nil { + return errMsg{err: err} + } + return secretDetailLoadedMsg{detail: detail} + } +} + +func (m Model) updateSecretList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if m.secretFilterActive { + switch key { + case "esc": + m.secretFilterActive = false + case "enter": + m.secretFilterActive = false + case "backspace": + if len(m.secretFilter) > 0 { + m.secretFilter = m.secretFilter[:len(m.secretFilter)-1] + m.applySecretFilter() + } + default: + if len(key) == 1 { + m.secretFilter += key + m.applySecretFilter() + } + } + return m, nil + } + + switch key { + case "q", "esc": + m.screen = screenFeatureList + m.secretFilter = "" + m.filteredSecrets = m.secrets + m.secretIdx = 0 + case "up", "k": + if m.secretIdx > 0 { + m.secretIdx-- + } + case "down", "j": + if m.secretIdx < len(m.filteredSecrets)-1 { + m.secretIdx++ + } + case "/": + m.secretFilterActive = true + case "enter": + if len(m.filteredSecrets) > 0 && m.secretIdx < len(m.filteredSecrets) { + selected := m.filteredSecrets[m.secretIdx] + m.screen = screenLoading + return m, m.loadSecretDetail(selected.Name) + } + } + return m, nil +} + +func (m Model) updateSecretDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.selectedSecret = nil + m.screen = screenSecretList + } + return m, nil +} + +func (m *Model) applySecretFilter() { + if m.secretFilter == "" { + m.filteredSecrets = m.secrets + } else { + query := strings.ToLower(m.secretFilter) + var result []awsservice.Secret + for _, s := range m.secrets { + if strings.Contains(s.FilterText(), query) { + result = append(result, s) + } + } + m.filteredSecrets = result + } + m.secretIdx = 0 +} + +func (m Model) viewSecretList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Secrets Manager")) + b.WriteString("\n") + + if m.secretFilterActive { + b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.secretFilter))) + } else if m.secretFilter != "" { + b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.secretFilter))) + } + b.WriteString("\n\n") + + if len(m.filteredSecrets) == 0 { + b.WriteString(dimStyle.Render(" No matching secrets")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.secretIdx >= visibleLines { + start = m.secretIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.filteredSecrets)) + + for i := start; i < end; i++ { + s := m.filteredSecrets[i] + cursor := " " + style := normalStyle + if i == m.secretIdx { + cursor = "> " + style = selectedStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle()))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d secrets", len(m.filteredSecrets), len(m.secrets)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home")) + return b.String() +} + +func (m Model) viewSecretDetail() string { + if m.selectedSecret == nil { + return "" + } + d := m.selectedSecret + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Secret Detail")) + b.WriteString("\n\n") + + labelStyle := lipgloss.NewStyle().Width(14) + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Name"), d.Name))) + b.WriteString("\n") + + kmsKey := d.KMSKeyID + if kmsKey == "" { + kmsKey = dimStyle.Render("(aws/secretsmanager)") + } + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Encryption Key"), kmsKey))) + b.WriteString("\n\n") + + if len(d.Values) > 0 { + b.WriteString(titleStyle.Render("Key / Value")) + b.WriteString("\n\n") + + keys := make([]string, 0, len(d.Values)) + for k := range d.Values { + keys = append(keys, k) + } + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && keys[j] < keys[j-1]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + + for _, k := range keys { + b.WriteString(fmt.Sprintf(" %s %s\n", dimStyle.Render(k), normalStyle.Render(d.Values[k]))) + } + } else if d.Raw != "" { + b.WriteString(titleStyle.Render("Value")) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", d.Raw))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("esc: back • H: home")) + return b.String() +} diff --git a/internal/app/messages.go b/internal/app/messages.go index 6d291f6..0d75265 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -74,3 +74,11 @@ type route53ZonesLoadedMsg struct { type route53RecordsLoadedMsg struct { records []awsservice.DNSRecord } + +type secretsLoadedMsg struct { + secrets []awsservice.Secret +} + +type secretDetailLoadedMsg struct { + detail *awsservice.SecretDetail +} diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index eb80794..9b05f5d 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -39,5 +39,14 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceSecretsManager, + Features: []Feature{ + { + Kind: FeatureSecretsBrowser, + Description: "Browse secrets and view key/value pairs", + }, + }, + }, } } diff --git a/internal/domain/model.go b/internal/domain/model.go index 74fd08d..0d7eeb4 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -4,20 +4,22 @@ package domain type AwsService string const ( - ServiceEC2 AwsService = "EC2" - ServiceVPC AwsService = "VPC" - ServiceRDS AwsService = "RDS" - ServiceRoute53 AwsService = "Route53" + ServiceEC2 AwsService = "EC2" + ServiceVPC AwsService = "VPC" + ServiceRDS AwsService = "RDS" + ServiceRoute53 AwsService = "Route53" + ServiceSecretsManager AwsService = "Secrets Manager" ) // FeatureKind represents a specific feature within a service. type FeatureKind string const ( - FeatureSSMSession FeatureKind = "SSM Sessions Manager" - FeatureVPCBrowser FeatureKind = "VPC Browser" - FeatureRDSBrowser FeatureKind = "RDS Browser" - FeatureRoute53Browser FeatureKind = "Route53 Browser" + FeatureSSMSession FeatureKind = "SSM Sessions Manager" + FeatureVPCBrowser FeatureKind = "VPC Browser" + FeatureRDSBrowser FeatureKind = "RDS Browser" + FeatureRoute53Browser FeatureKind = "Route53 Browser" + FeatureSecretsBrowser FeatureKind = "Secrets Manager Browser" ) // Feature describes a selectable feature under an AWS service. diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 9669777..3055398 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/route53" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -29,6 +30,9 @@ var _ RDSClientAPI = (*rds.Client)(nil) // Verify *route53.Client satisfies Route53ClientAPI at compile time. var _ Route53ClientAPI = (*route53.Client)(nil) +// Verify *secretsmanager.Client satisfies SecretsManagerClientAPI at compile time. +var _ SecretsManagerClientAPI = (*secretsmanager.Client)(nil) + // SSMClientAPI is the interface for SSM operations used by AwsRepository. type SSMClientAPI interface { ssm.DescribeInstanceInformationAPIClient @@ -53,6 +57,12 @@ type Route53ClientAPI interface { ListResourceRecordSets(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) } +// SecretsManagerClientAPI is the interface for Secrets Manager operations used by AwsRepository. +type SecretsManagerClientAPI interface { + ListSecrets(ctx context.Context, params *secretsmanager.ListSecretsInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + // EC2ClientAPI is the interface for EC2 operations used by AwsRepository. type EC2ClientAPI interface { ec2.DescribeInstancesAPIClient @@ -68,15 +78,16 @@ type CallerIdentity struct { UserID string } -// AwsRepository holds AWS SDK clients for EC2, SSM, RDS, and STS. +// AwsRepository holds AWS SDK clients for EC2, SSM, RDS, Route53, STS, and Secrets Manager. type AwsRepository struct { - EC2Client EC2ClientAPI - SSMClient SSMClientAPI - RDSClient RDSClientAPI - Route53Client Route53ClientAPI - STSClient *sts.Client - Region string - Profile string + EC2Client EC2ClientAPI + SSMClient SSMClientAPI + RDSClient RDSClientAPI + Route53Client Route53ClientAPI + SecretsManagerClient SecretsManagerClientAPI + STSClient *sts.Client + Region string + Profile string } // NewAwsRepository creates a new AwsRepository with configured EC2 and SSM clients. @@ -134,13 +145,14 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, } return &AwsRepository{ - EC2Client: ec2.NewFromConfig(awsCfg), - SSMClient: ssm.NewFromConfig(awsCfg), - RDSClient: rds.NewFromConfig(awsCfg), - Route53Client: route53.NewFromConfig(awsCfg), - STSClient: sts.NewFromConfig(awsCfg), - Region: cfg.Region, - Profile: cfg.Profile, + EC2Client: ec2.NewFromConfig(awsCfg), + SSMClient: ssm.NewFromConfig(awsCfg), + RDSClient: rds.NewFromConfig(awsCfg), + Route53Client: route53.NewFromConfig(awsCfg), + SecretsManagerClient: secretsmanager.NewFromConfig(awsCfg), + STSClient: sts.NewFromConfig(awsCfg), + Region: cfg.Region, + Profile: cfg.Profile, }, nil } diff --git a/internal/services/aws/secretsmanager.go b/internal/services/aws/secretsmanager.go new file mode 100644 index 0000000..c33c0c5 --- /dev/null +++ b/internal/services/aws/secretsmanager.go @@ -0,0 +1,57 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// ListSecrets returns all secrets in the current account/region. +func (r *AwsRepository) ListSecrets(ctx context.Context) ([]Secret, error) { + output, err := r.SecretsManagerClient.ListSecrets(ctx, &secretsmanager.ListSecretsInput{}) + if err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + + secrets := make([]Secret, 0, len(output.SecretList)) + for _, s := range output.SecretList { + secrets = append(secrets, Secret{ + Name: awssdk.ToString(s.Name), + ARN: awssdk.ToString(s.ARN), + Description: awssdk.ToString(s.Description), + KMSKeyID: awssdk.ToString(s.KmsKeyId), + }) + } + return secrets, nil +} + +// GetSecretDetail retrieves the full detail of a secret including its value. +func (r *AwsRepository) GetSecretDetail(ctx context.Context, secretName string) (*SecretDetail, error) { + output, err := r.SecretsManagerClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: awssdk.String(secretName), + }) + if err != nil { + return nil, fmt.Errorf("failed to get secret value for %s: %w", secretName, err) + } + + detail := &SecretDetail{ + Secret: Secret{ + Name: awssdk.ToString(output.Name), + ARN: awssdk.ToString(output.ARN), + }, + } + + raw := awssdk.ToString(output.SecretString) + detail.Raw = raw + + // Attempt to parse as JSON key/value map + var kv map[string]string + if err := json.Unmarshal([]byte(raw), &kv); err == nil { + detail.Values = kv + } + + return detail, nil +} diff --git a/internal/services/aws/secretsmanager_model.go b/internal/services/aws/secretsmanager_model.go new file mode 100644 index 0000000..e609e1e --- /dev/null +++ b/internal/services/aws/secretsmanager_model.go @@ -0,0 +1,34 @@ +package aws + +import ( + "fmt" + "strings" +) + +// Secret holds essential information about an AWS Secrets Manager secret. +type Secret struct { + Name string + ARN string + Description string + KMSKeyID string +} + +// SecretDetail holds the full detail of a secret including its key/value pairs. +type SecretDetail struct { + Secret + Values map[string]string // parsed key/value pairs from JSON secret string + Raw string // raw secret string (for non-JSON secrets) +} + +// DisplayTitle returns a formatted string for list display. +func (s Secret) DisplayTitle() string { + if s.Description != "" { + return fmt.Sprintf("%s — %s", s.Name, s.Description) + } + return s.Name +} + +// FilterText returns a lowercase string for keyword matching. +func (s Secret) FilterText() string { + return strings.ToLower(fmt.Sprintf("%s %s %s", s.Name, s.Description, s.ARN)) +} diff --git a/internal/services/aws/secretsmanager_test.go b/internal/services/aws/secretsmanager_test.go new file mode 100644 index 0000000..ea94993 --- /dev/null +++ b/internal/services/aws/secretsmanager_test.go @@ -0,0 +1,208 @@ +package aws + +import ( + "context" + "fmt" + "strings" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" +) + +// mockSecretsManagerClient implements SecretsManagerClientAPI for testing. +type mockSecretsManagerClient struct { + listSecretsFunc func(ctx context.Context, params *secretsmanager.ListSecretsInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) + getSecretValueFunc func(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +func (m *mockSecretsManagerClient) ListSecrets(ctx context.Context, params *secretsmanager.ListSecretsInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) { + return m.listSecretsFunc(ctx, params, optFns...) +} + +func (m *mockSecretsManagerClient) GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + return m.getSecretValueFunc(ctx, params, optFns...) +} + +// --- ListSecrets tests --- + +func TestListSecrets_Success(t *testing.T) { + mock := &mockSecretsManagerClient{ + listSecretsFunc: func(_ context.Context, _ *secretsmanager.ListSecretsInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) { + return &secretsmanager.ListSecretsOutput{ + SecretList: []smtypes.SecretListEntry{ + { + Name: awssdk.String("prod/db/password"), + ARN: awssdk.String("arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password"), + Description: awssdk.String("Production DB password"), + KmsKeyId: awssdk.String("alias/aws/secretsmanager"), + }, + { + Name: awssdk.String("dev/api/key"), + ARN: awssdk.String("arn:aws:secretsmanager:us-east-1:123456789012:secret:dev/api/key"), + Description: nil, + KmsKeyId: nil, + }, + }, + }, nil + }, + } + + repo := &AwsRepository{SecretsManagerClient: mock} + secrets, err := repo.ListSecrets(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(secrets) != 2 { + t.Fatalf("expected 2 secrets, got %d", len(secrets)) + } + + s := secrets[0] + if s.Name != "prod/db/password" { + t.Errorf("expected Name 'prod/db/password', got %q", s.Name) + } + if s.Description != "Production DB password" { + t.Errorf("expected Description 'Production DB password', got %q", s.Description) + } + if s.KMSKeyID != "alias/aws/secretsmanager" { + t.Errorf("expected KMSKeyID 'alias/aws/secretsmanager', got %q", s.KMSKeyID) + } + + s2 := secrets[1] + if s2.KMSKeyID != "" { + t.Errorf("expected empty KMSKeyID for nil input, got %q", s2.KMSKeyID) + } +} + +func TestListSecrets_Empty(t *testing.T) { + mock := &mockSecretsManagerClient{ + listSecretsFunc: func(_ context.Context, _ *secretsmanager.ListSecretsInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) { + return &secretsmanager.ListSecretsOutput{SecretList: []smtypes.SecretListEntry{}}, nil + }, + } + + repo := &AwsRepository{SecretsManagerClient: mock} + secrets, err := repo.ListSecrets(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(secrets) != 0 { + t.Errorf("expected empty slice, got %d", len(secrets)) + } +} + +func TestListSecrets_Error(t *testing.T) { + mock := &mockSecretsManagerClient{ + listSecretsFunc: func(_ context.Context, _ *secretsmanager.ListSecretsInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) { + return nil, fmt.Errorf("access denied") + }, + } + + repo := &AwsRepository{SecretsManagerClient: mock} + _, err := repo.ListSecrets(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// --- GetSecretDetail tests --- + +func TestGetSecretDetail_JSONValue(t *testing.T) { + mock := &mockSecretsManagerClient{ + getSecretValueFunc: func(_ context.Context, params *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + if awssdk.ToString(params.SecretId) != "prod/db/password" { + t.Errorf("expected SecretId 'prod/db/password', got %q", awssdk.ToString(params.SecretId)) + } + return &secretsmanager.GetSecretValueOutput{ + Name: awssdk.String("prod/db/password"), + ARN: awssdk.String("arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password"), + SecretString: awssdk.String(`{"username":"admin","password":"s3cr3t"}`), + }, nil + }, + } + + repo := &AwsRepository{SecretsManagerClient: mock} + detail, err := repo.GetSecretDetail(context.Background(), "prod/db/password") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detail.Name != "prod/db/password" { + t.Errorf("expected Name 'prod/db/password', got %q", detail.Name) + } + if detail.Values["username"] != "admin" { + t.Errorf("expected username 'admin', got %q", detail.Values["username"]) + } + if detail.Values["password"] != "s3cr3t" { + t.Errorf("expected password 's3cr3t', got %q", detail.Values["password"]) + } +} + +func TestGetSecretDetail_PlainStringValue(t *testing.T) { + mock := &mockSecretsManagerClient{ + getSecretValueFunc: func(_ context.Context, _ *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + return &secretsmanager.GetSecretValueOutput{ + Name: awssdk.String("my-token"), + ARN: awssdk.String("arn:aws:secretsmanager:us-east-1:123456789012:secret:my-token"), + SecretString: awssdk.String("plain-text-token"), + }, nil + }, + } + + repo := &AwsRepository{SecretsManagerClient: mock} + detail, err := repo.GetSecretDetail(context.Background(), "my-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if detail.Raw != "plain-text-token" { + t.Errorf("expected Raw 'plain-text-token', got %q", detail.Raw) + } + if len(detail.Values) != 0 { + t.Errorf("expected empty Values for non-JSON secret, got %v", detail.Values) + } +} + +func TestGetSecretDetail_Error(t *testing.T) { + mock := &mockSecretsManagerClient{ + getSecretValueFunc: func(_ context.Context, _ *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + return nil, fmt.Errorf("ResourceNotFoundException") + }, + } + + repo := &AwsRepository{SecretsManagerClient: mock} + _, err := repo.GetSecretDetail(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// --- Model tests --- + +func TestSecretDisplayTitle_WithDescription(t *testing.T) { + s := Secret{Name: "prod/db/password", Description: "Production DB password"} + expected := "prod/db/password — Production DB password" + if got := s.DisplayTitle(); got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +func TestSecretDisplayTitle_NoDescription(t *testing.T) { + s := Secret{Name: "dev/api/key"} + if got := s.DisplayTitle(); got != "dev/api/key" { + t.Errorf("expected 'dev/api/key', got %q", got) + } +} + +func TestSecretFilterText(t *testing.T) { + s := Secret{ + Name: "Prod/DB/Password", + Description: "Production Database", + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod", + } + ft := s.FilterText() + for _, kw := range []string{"prod/db/password", "production database", "arn:aws"} { + if !strings.Contains(ft, kw) { + t.Errorf("FilterText %q should contain %q", ft, kw) + } + } +}