Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ contexts:
| Service | Feature | Status |
|---------|---------|--------|
| EC2 | SSM Session Manager (connect to EC2 instances) | ✅ Implemented |
| EC2 | Security Group Browser (list/filter SGs, view inbound/outbound rules) | ✅ Implemented |
| VPC | VPC Browser (VPCs → subnets → available IPs) | ✅ Implemented |
| RDS | RDS Browser (list, start/stop, failover, Aurora cluster support) | ✅ Implemented |
| Route53 | ListHostedZones | 🚧 Coming Soon |
Expand Down
28 changes: 28 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const (
screenRoute53RecordDetail
screenSecretList
screenSecretDetail
screenSecurityGroupList
screenSecurityGroupDetail
screenContextPicker
screenContextAdd
screenLoading
Expand Down Expand Up @@ -109,6 +111,14 @@ type Model struct {
secretFilterActive bool
selectedSecret *awsservice.SecretDetail

// Security Group browser state
securityGroups []awsservice.SecurityGroup
filteredSecurityGroups []awsservice.SecurityGroup
sgIdx int
sgFilter string
sgFilterActive bool
selectedSecurityGroup *awsservice.SecurityGroup

// Context picker
configPath string
ctxList []config.ContextInfo
Expand Down Expand Up @@ -240,6 +250,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.screen = screenSecretDetail
return m, nil

case securityGroupsLoadedMsg:
m.securityGroups = msg.securityGroups
m.filteredSecurityGroups = msg.securityGroups
m.sgIdx = 0
m.screen = screenSecurityGroupList
return m, nil

case rdsActionDoneMsg:
if msg.err != nil {
m.errMsg = msg.err.Error()
Expand Down Expand Up @@ -365,6 +382,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSecretList(msg)
case screenSecretDetail:
return m.updateSecretDetail(msg)
case screenSecurityGroupList:
return m.updateSecurityGroupList(msg)
case screenSecurityGroupDetail:
return m.updateSecurityGroupDetail(msg)
case screenContextPicker:
return m.updateContextPicker(msg)
case screenContextAdd:
Expand Down Expand Up @@ -434,6 +455,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case domain.FeatureSecretsBrowser:
m.screen = screenLoading
return m, m.loadSecrets()
case domain.FeatureSecurityGroupBrowser:
m.screen = screenLoading
return m, m.loadSecurityGroups()
}
}
}
Expand Down Expand Up @@ -487,6 +511,10 @@ func (m Model) View() string {
v = m.viewSecretList()
case screenSecretDetail:
v = m.viewSecretDetail()
case screenSecurityGroupList:
v = m.viewSecurityGroupList()
case screenSecurityGroupDetail:
v = m.viewSecurityGroupDetail()
case screenContextPicker:
v = m.viewContextPicker()
case screenContextAdd:
Expand Down
128 changes: 128 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -588,3 +588,131 @@ func TestViewFitsTerminalHeight(t *testing.T) {
t.Errorf("view output has %d lines, exceeds terminal height %d", len(lines), m.height)
}
}

// --- Security Group tests ---

func TestSecurityGroupListNavigation(t *testing.T) {
m := New(testConfig(), "")
m.screen = screenSecurityGroupList
m.securityGroups = []awsservice.SecurityGroup{
{GroupID: "sg-1", Name: "web", VPCID: "vpc-1"},
{GroupID: "sg-2", Name: "db", VPCID: "vpc-1"},
}
m.filteredSecurityGroups = m.securityGroups

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
model := updated.(Model)
if model.sgIdx != 1 {
t.Errorf("expected sgIdx 1, got %d", model.sgIdx)
}

updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
model = updated.(Model)
if model.sgIdx != 0 {
t.Errorf("expected sgIdx 0, got %d", model.sgIdx)
}
}

func TestSecurityGroupListEnterGoesToDetail(t *testing.T) {
m := New(testConfig(), "")
m.screen = screenSecurityGroupList
m.securityGroups = []awsservice.SecurityGroup{
{GroupID: "sg-1", Name: "web", VPCID: "vpc-1"},
}
m.filteredSecurityGroups = m.securityGroups

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
model := updated.(Model)
if model.screen != screenSecurityGroupDetail {
t.Errorf("expected detail screen, got %d", model.screen)
}
if model.selectedSecurityGroup == nil {
t.Error("selectedSecurityGroup should not be nil")
}
}

func TestSecurityGroupDetailEscGoesBack(t *testing.T) {
m := New(testConfig(), "")
m.screen = screenSecurityGroupDetail
m.selectedSecurityGroup = &awsservice.SecurityGroup{GroupID: "sg-1"}

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
model := updated.(Model)
if model.screen != screenSecurityGroupList {
t.Errorf("expected list screen, got %d", model.screen)
}
}

func TestSecurityGroupFilter(t *testing.T) {
m := New(testConfig(), "")
m.screen = screenSecurityGroupList
m.securityGroups = []awsservice.SecurityGroup{
{GroupID: "sg-1", Name: "web-sg", VPCID: "vpc-1"},
{GroupID: "sg-2", Name: "db-sg", VPCID: "vpc-1"},
}
m.filteredSecurityGroups = m.securityGroups

// Activate filter
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
model := updated.(Model)
if !model.sgFilterActive {
t.Error("filter should be active")
}

// Type "web"
for _, ch := range "web" {
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}})
model = updated.(Model)
}
if len(model.filteredSecurityGroups) != 1 {
t.Errorf("expected 1 filtered SG, got %d", len(model.filteredSecurityGroups))
}
}

func TestSecurityGroupDetailView(t *testing.T) {
m := New(testConfig(), "")
m.screen = screenSecurityGroupDetail
m.height = 30
m.selectedSecurityGroup = &awsservice.SecurityGroup{
GroupID: "sg-aaa",
Name: "web-sg",
Description: "Web servers",
VPCID: "vpc-111",
IngressRules: []awsservice.SecurityGroupRule{
{Protocol: "tcp", FromPort: 443, ToPort: 443, CIDRV4: "0.0.0.0/0", Description: "HTTPS"},
},
EgressRules: []awsservice.SecurityGroupRule{
{Protocol: "-1", CIDRV4: "0.0.0.0/0"},
},
}

v := m.View()
if !strings.Contains(v, "sg-aaa") {
t.Error("detail view should contain group ID")
}
if !strings.Contains(v, "Inbound Rules") {
t.Error("detail view should show inbound rules section")
}
if !strings.Contains(v, "Outbound Rules") {
t.Error("detail view should show outbound rules section")
}
if !strings.Contains(v, "443") {
t.Error("detail view should show port 443")
}
}

func TestSecurityGroupBrowserInCatalog(t *testing.T) {
catalog := domain.Catalog()
for _, svc := range catalog {
if svc.Name == domain.ServiceEC2 {
for _, feat := range svc.Features {
if feat.Kind == domain.FeatureSecurityGroupBrowser {
return
}
}
t.Error("EC2 should have Security Group Browser feature")
return
}
}
t.Error("EC2 service not found")
}
4 changes: 4 additions & 0 deletions internal/app/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,7 @@ type secretsLoadedMsg struct {
type secretDetailLoadedMsg struct {
detail *awsservice.SecretDetail
}

type securityGroupsLoadedMsg struct {
securityGroups []awsservice.SecurityGroup
}
Loading
Loading