From 6cb00af9decb100556e0d6ef9a789c7d51390995 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 08:21:35 +0900 Subject: [PATCH 1/3] feat: expose SQS queues to automation - add a read-only JSON resource command and MCP tool - preserve backlog ordering and dead-letter queue relationships - document the contract and required permissions --- README.md | 6 ++-- docs/development.md | 2 ++ internal/cli/resources.go | 2 +- internal/cli/resources_operations.go | 17 +++++++++++ internal/cli/resources_operations_test.go | 37 +++++++++++++++++++++++ internal/mcp/agent_surface_test.go | 2 +- internal/mcp/server.go | 13 ++++++-- internal/mcp/server_test.go | 1 + internal/services/aws/sqs.go | 28 ++++++++--------- 9 files changed, 88 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 67b9efe0..0006a1e3 100644 --- a/README.md +++ b/README.md @@ -164,10 +164,11 @@ For automation, `unic resources --json` runs a read-only AWS query using - `ecs-rollout --cluster --service ` - `cloudtrail-events [--since 24h] [--resource ] [--mutations-only]` - `elb-target-health --load-balancer ` +- `sqs-queues` Inspector runs outside `resources` because it is a scan, not a resource listing. `unic inspect --json` runs every built-in security and cost/waste rule pack against the active context and returns the same v1 envelope, with `data` carrying `scanned_at`, `scanner_count`, `finding_count`, `severity_counts`, and the `findings` array. Rule packs that fail — most often a denied API call — appear in `warnings` rather than being dropped, so a partially blocked scan is never reported as a clean one. The equivalent MCP tool is `run_security_inspector`. The root `--checklist` flag is inherited but rejected here: Checklist Inspector produces a different report shape and has no agent contract yet, so it fails loudly rather than returning security findings in its place. -The same operations are exposed by `unic-mcp` as read-only tools. Call `get_mcp_capabilities` to discover their versioned input contracts, strict input schemas, output contracts, pagination behavior, and required IAM permissions. The operation permissions are `ec2:DescribeInstances`, `rds:DescribeDBInstances`, `cloudwatch:DescribeAlarms`, `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, `cloudtrail:LookupEvents`, `elasticloadbalancing:DescribeTargetGroups`, and `elasticloadbalancing:DescribeTargetHealth`; AWS Backup retains the permissions documented below. CLI and MCP output never includes resolved credentials. +The same operations are exposed by `unic-mcp` as read-only tools. Call `get_mcp_capabilities` to discover their versioned input contracts, strict input schemas, output contracts, pagination behavior, and required IAM permissions. The operation permissions are `ec2:DescribeInstances`, `rds:DescribeDBInstances`, `cloudwatch:DescribeAlarms`, `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, `cloudtrail:LookupEvents`, `elasticloadbalancing:DescribeTargetGroups`, `elasticloadbalancing:DescribeTargetHealth`, `sqs:ListQueues`, and `sqs:GetQueueAttributes`; AWS Backup retains the permissions documented below. CLI and MCP output never includes resolved credentials. ### MCP server @@ -249,10 +250,11 @@ For Claude Desktop and other JSON-configured MCP clients, use: In Kiro, open **Powers**, choose **Add Custom Power**, and import this repository from GitHub. The root `plugin.json`, `mcp.json`, and `skills/` directory follow the Agent Plugins format used by Kiro Powers. -The server provides `get_mcp_capabilities`, `get_capabilities`, `get_command_schema`, `list_backup_vaults`, `run_security_inspector`, and `plan_context_sync`. Agents should call `get_mcp_capabilities` first because it describes only operations callable through MCP, including permissions and output contracts. Example prompts: +The server provides the read-only resource operations listed above, including `list_sqs_queues`, plus capability discovery, Security Inspector, and context-sync preview tools. Agents should call `get_mcp_capabilities` first because it describes only operations callable through MCP, including permissions and output contracts. Example prompts: - `Show the AWS capabilities available through unic.` - `List my AWS Backup vaults in ap-northeast-2.` +- `Show my deepest SQS backlogs and their dead-letter queue relationships.` - `Preview a unic context sync without changing config.` The context-sync tool is preview-only: it never passes `--apply` or writes configuration. If a client cannot start the server, verify `unic-mcp` is on the client's `PATH` and that the required AWS profile or SSO session is available in the client process environment. diff --git a/docs/development.md b/docs/development.md index 97651782..5d3ddcbe 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,6 +31,8 @@ Discovery output is deterministic, versioned JSON. New executable commands shoul Read-only automation commands live under `internal/cli/`; keep their `--json` output versioned and deterministic, write only JSON to stdout, and cover human and JSON output paths with CLI tests. +`unic resources sqs-queues --json` reuses the backlog ordering and DLQ relationships from `AwsRepository.ListQueues`. Keep that CLI/MCP contract read-only; SQS purge and redrive remain confirmation-gated TUI mutations. + The stdio MCP entry point lives at `cmd/unic-mcp` and delegates tool calls to those same CLI commands through `internal/cli.ExecuteAutomation`. Keep the MCP layer limited to protocol handling and argument mapping; AWS and config behavior belongs in the existing CLI, auth, and service packages. MCP mutation tools remain preview-only until their trust boundary is reviewed. The repository root is also the portable agent-plugin package. Keep shared MCP guidance in `skills/unic-aws`, Kiro metadata in `plugin.json` and `mcp.json`, and client-specific manifests in `.codex-plugin`, `.claude-plugin`, and `.mcp.json`. All clients must launch the released `unic-mcp` binary from `PATH`; do not add client-specific MCP implementations. diff --git a/internal/cli/resources.go b/internal/cli/resources.go index cf2f4f9b..65c8348e 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -58,7 +58,7 @@ func newResourcesCmd() *cobra.Command { cmd := &cobra.Command{Use: "resources", Short: "Read-only resource queries for automation"} cmd.AddCommand(newBackupVaultsCmd()) cmd.AddCommand(newEC2InstancesCmd(), newRDSInstancesCmd(), newAlarmsCmd()) - cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd()) + cmd.AddCommand(newECSRolloutCmd(), newCloudTrailEventsCmd(), newELBTargetHealthCmd(), newSQSQueuesCmd()) return cmd } diff --git a/internal/cli/resources_operations.go b/internal/cli/resources_operations.go index 4444dfbd..0a853e78 100644 --- a/internal/cli/resources_operations.go +++ b/internal/cli/resources_operations.go @@ -70,6 +70,13 @@ var ( } return repo.ListTargetGroupHealth(ctx, arn) } + loadSQSQueues = func(ctx context.Context) ([]awsservice.SQSQueue, error) { + repo, err := resourceRepository(ctx) + if err != nil { + return nil, err + } + return repo.ListQueues(ctx) + } ) func writeResourceJSON(cmd *cobra.Command, data any, complete bool, warnings []string) error { @@ -180,3 +187,13 @@ func newELBTargetHealthCmd() *cobra.Command { _ = cmd.MarkFlagRequired("load-balancer") return cmd } + +func newSQSQueuesCmd() *cobra.Command { + return jsonResourceCommand("sqs-queues", "List SQS queues by backlog as JSON", func(ctx context.Context) (any, error) { + items, err := loadSQSQueues(ctx) + if items == nil { + items = []awsservice.SQSQueue{} + } + return items, err + }) +} diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index ac319b8b..3a349f91 100644 --- a/internal/cli/resources_operations_test.go +++ b/internal/cli/resources_operations_test.go @@ -66,6 +66,43 @@ func TestEC2InstancesEmptyDataIsArrayAndDiscoveryIsReadOnlyV1(t *testing.T) { } } +func TestSQSQueuesJSONContract(t *testing.T) { + original := loadSQSQueues + defer func() { loadSQSQueues = original }() + loadSQSQueues = func(context.Context) ([]awsservice.SQSQueue, error) { + return []awsservice.SQSQueue{{ + Name: "orders-dlq", ARN: "arn:aws:sqs:us-east-1:123456789012:orders-dlq", + Region: "us-east-1", Depth: 42, SourceQueueARNs: []string{"arn:aws:sqs:us-east-1:123456789012:orders"}, SourceQueueCount: 1, + }}, nil + } + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "sqs-queues", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var result struct { + SchemaVersion string `json:"schema_version"` + Data []struct { + Name string `json:"name"` + Depth int `json:"depth"` + SourceQueueARNs []string `json:"source_queue_arns"` + SourceQueueCount int `json:"source_queue_count"` + } `json:"data"` + Warnings []string `json:"warnings"` + Pagination jsonPagination `json:"pagination"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.SchemaVersion != "v1" || len(result.Data) != 1 || result.Data[0].Name != "orders-dlq" || + result.Data[0].Depth != 42 || len(result.Data[0].SourceQueueARNs) != 1 || result.Data[0].SourceQueueCount != 1 || + result.Warnings == nil || !result.Pagination.Complete { + t.Fatalf("unexpected result: %+v", result) + } +} + func TestCloudTrailEventsReportsCapAsIncomplete(t *testing.T) { original := loadCloudTrailEvents defer func() { loadCloudTrailEvents = original }() diff --git a/internal/mcp/agent_surface_test.go b/internal/mcp/agent_surface_test.go index 7d9d24c1..55d2da0f 100644 --- a/internal/mcp/agent_surface_test.go +++ b/internal/mcp/agent_surface_test.go @@ -29,6 +29,7 @@ var agentSurfaceByFeature = map[domain.FeatureKind]agentSurface{ domain.FeatureECSExec: {command: "ecs-rollout", tool: "get_ecs_service_rollout", arguments: json.RawMessage(`{"cluster":"cluster","service":"service"}`)}, domain.FeatureELBBrowser: {command: "elb-target-health", tool: "get_elb_target_health", arguments: json.RawMessage(`{"load_balancer":"load-balancer"}`)}, domain.FeatureRDSBrowser: {command: "rds-instances", tool: "list_rds_instances"}, + domain.FeatureSQSBrowser: {command: "sqs-queues", tool: "list_sqs_queues"}, } var agentSurfaceExempt = map[domain.FeatureKind]string{ @@ -57,7 +58,6 @@ var agentSurfaceExempt = map[domain.FeatureKind]string{ domain.FeatureSecurityGroupBrowser: "no curated security-group rule query is defined yet", domain.FeatureSecretsBrowser: "secret values require operator-controlled reveal and copy handling", domain.FeatureSNSBrowser: "the joined topic and subscription view has no agent contract yet", - domain.FeatureSQSBrowser: "queue mutations are confirmation-gated and no separate read-only contract exists yet", domain.FeatureSSMParameterBrowser: "parameter values require operator-controlled reveal and copy handling", domain.FeatureSSMSession: "starts an interactive shell session instead of returning resource data", domain.FeatureStepFunctionsBrowser: "the failure-first execution view has no curated agent contract yet", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b43d9ad6..8c770903 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -153,6 +153,15 @@ var tools = []tool{ Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, Metadata: toolMetadata{RequiredPermissions: []string{"elasticloadbalancing:DescribeTargetGroups", "elasticloadbalancing:DescribeTargetHealth"}, OutputContract: "unic.resources.elb-target-health.v1", Paginated: true}, }, + { + Name: "list_sqs_queues", Description: "List SQS queues by backlog with dead-letter queue relationships.", + InputSchema: awsContextSchema(nil, nil), + Annotations: annotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: true}, + Metadata: toolMetadata{ + RequiredPermissions: []string{"sqs:ListQueues", "sqs:GetQueueAttributes"}, + OutputContract: "unic.resources.sqs-queues.v1", Paginated: true, + }, + }, { Name: "plan_context_sync", Description: "Preview an SSO context sync plan. This tool never writes configuration.", InputSchema: objectSchema(map[string]any{ @@ -405,7 +414,7 @@ func toolArgs(name string, raw json.RawMessage) ([]string, error) { result = append(result, "--region", args.Region) } return result, nil - case "list_ec2_instances", "list_rds_instances", "list_cloudwatch_alarms": + case "list_ec2_instances", "list_rds_instances", "list_cloudwatch_alarms", "list_sqs_queues": var args struct { Profile string `json:"profile"` Region string `json:"region"` @@ -413,7 +422,7 @@ func toolArgs(name string, raw json.RawMessage) ([]string, error) { if err := decodeArguments(raw, &args); err != nil { return nil, err } - command := map[string]string{"list_ec2_instances": "ec2-instances", "list_rds_instances": "rds-instances", "list_cloudwatch_alarms": "alarms"}[name] + command := map[string]string{"list_ec2_instances": "ec2-instances", "list_rds_instances": "rds-instances", "list_cloudwatch_alarms": "alarms", "list_sqs_queues": "sqs-queues"}[name] return withAWSContext([]string{"resources", command, "--json"}, args.Profile, args.Region), nil case "get_ecs_service_rollout": var args struct { diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index e139f006..7f7a237c 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -55,6 +55,7 @@ func TestReadOnlyOperationToolArgs(t *testing.T) { want []string }{ {"list_ec2_instances", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "ec2-instances", "--json", "--profile", "prod", "--region", "eu-west-1"}}, + {"list_sqs_queues", `{"profile":"prod","region":"eu-west-1"}`, []string{"resources", "sqs-queues", "--json", "--profile", "prod", "--region", "eu-west-1"}}, {"get_ecs_service_rollout", `{"cluster":"prod","service":"api"}`, []string{"resources", "ecs-rollout", "--cluster", "prod", "--service", "api", "--json"}}, {"list_cloudtrail_events", `{"since":"6h","mutations_only":true}`, []string{"resources", "cloudtrail-events", "--since", "6h", "--json", "--mutations-only"}}, {"get_elb_target_health", `{"load_balancer":"arn:lb"}`, []string{"resources", "elb-target-health", "--load-balancer", "arn:lb", "--json"}}, diff --git a/internal/services/aws/sqs.go b/internal/services/aws/sqs.go index 115ebffc..bba5cc5e 100644 --- a/internal/services/aws/sqs.go +++ b/internal/services/aws/sqs.go @@ -17,20 +17,20 @@ import ( // SQSQueue is one queue with the backlog fields operators triage by. type SQSQueue struct { - Name string - URL string - ARN string - Region string - Depth int // ApproximateNumberOfMessages - InFlight int // ApproximateNumberOfMessagesNotVisible - Delayed int - VisibilitySec int - RetentionSec int - Fifo bool - DLQTargetARN string // where this queue's failures go - MaxReceiveCount int - SourceQueueARNs []string // queues that dead-letter into this one - SourceQueueCount int // len(SourceQueueARNs), kept for display + Name string `json:"name"` + URL string `json:"url"` + ARN string `json:"arn"` + Region string `json:"region"` + Depth int `json:"depth"` // ApproximateNumberOfMessages + InFlight int `json:"in_flight"` // ApproximateNumberOfMessagesNotVisible + Delayed int `json:"delayed"` + VisibilitySec int `json:"visibility_seconds"` + RetentionSec int `json:"retention_seconds"` + Fifo bool `json:"fifo"` + DLQTargetARN string `json:"dlq_target_arn,omitempty"` // where this queue's failures go + MaxReceiveCount int `json:"max_receive_count,omitempty"` + SourceQueueARNs []string `json:"source_queue_arns"` // queues that dead-letter into this one + SourceQueueCount int `json:"source_queue_count"` // len(SourceQueueARNs), kept for display } // IsDLQ reports whether other queues dead-letter into this queue. From 97d208ff4541a921cb2c1de5a79a0041feceb0f2 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 08:28:57 +0900 Subject: [PATCH 2/3] fix: enable SQS queue pagination - request the maximum page size so AWS returns continuation tokens - assert the paginator input in the existing multi-page test --- internal/services/aws/sqs.go | 3 ++- internal/services/aws/sqs_test.go | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/services/aws/sqs.go b/internal/services/aws/sqs.go index bba5cc5e..04d864d0 100644 --- a/internal/services/aws/sqs.go +++ b/internal/services/aws/sqs.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sqs" sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" @@ -69,7 +70,7 @@ func (r *AwsRepository) ListQueues(ctx context.Context) ([]SQSQueue, error) { uniclog.Debug("aws", "ListQueues called") var urls []string - paginator := sqs.NewListQueuesPaginator(r.SQSClient, &sqs.ListQueuesInput{}) + paginator := sqs.NewListQueuesPaginator(r.SQSClient, &sqs.ListQueuesInput{MaxResults: awssdk.Int32(1000)}) for paginator.HasMorePages() { page, err := paginator.NextPage(ctx) if err != nil { diff --git a/internal/services/aws/sqs_test.go b/internal/services/aws/sqs_test.go index 66779aad..f9a5926e 100644 --- a/internal/services/aws/sqs_test.go +++ b/internal/services/aws/sqs_test.go @@ -133,6 +133,9 @@ func TestListQueuesAggregatesPages(t *testing.T) { page := 0 mock := &mockSQSClient{ listQueuesFunc: func(_ context.Context, params *sqs.ListQueuesInput, _ ...func(*sqs.Options)) (*sqs.ListQueuesOutput, error) { + if got := awssdk.ToInt32(params.MaxResults); got != 1000 { + t.Fatalf("expected max SQS page size, got %d", got) + } page++ if page == 1 { token := "next" From af34684b775e37477f7f68219399e7498482ec9a Mon Sep 17 00:00:00 2001 From: YoungJin Date: Tue, 15 Sep 2026 08:31:46 +0900 Subject: [PATCH 3/3] fix: stabilize SQS queue relationships - sort DLQ source ARNs and preserve empty arrays - cover multi-source, zero-source, and loader-error contracts --- internal/cli/resources_operations_test.go | 35 +++++++++++++++++++---- internal/services/aws/sqs.go | 3 +- internal/services/aws/sqs_test.go | 23 ++++++++++++--- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/internal/cli/resources_operations_test.go b/internal/cli/resources_operations_test.go index 3a349f91..1cb41cf9 100644 --- a/internal/cli/resources_operations_test.go +++ b/internal/cli/resources_operations_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "testing" awsservice "unic/internal/services/aws" @@ -70,10 +71,14 @@ func TestSQSQueuesJSONContract(t *testing.T) { original := loadSQSQueues defer func() { loadSQSQueues = original }() loadSQSQueues = func(context.Context) ([]awsservice.SQSQueue, error) { - return []awsservice.SQSQueue{{ - Name: "orders-dlq", ARN: "arn:aws:sqs:us-east-1:123456789012:orders-dlq", - Region: "us-east-1", Depth: 42, SourceQueueARNs: []string{"arn:aws:sqs:us-east-1:123456789012:orders"}, SourceQueueCount: 1, - }}, nil + return []awsservice.SQSQueue{ + { + Name: "orders-dlq", ARN: "arn:aws:sqs:us-east-1:123456789012:orders-dlq", + Region: "us-east-1", Depth: 42, + SourceQueueARNs: []string{"arn:aws:sqs:us-east-1:123456789012:orders", "arn:aws:sqs:us-east-1:123456789012:payments"}, SourceQueueCount: 2, + }, + {Name: "idle", SourceQueueARNs: []string{}}, + }, nil } cmd := NewRootCmd() var output bytes.Buffer @@ -96,13 +101,31 @@ func TestSQSQueuesJSONContract(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &result); err != nil { t.Fatal(err) } - if result.SchemaVersion != "v1" || len(result.Data) != 1 || result.Data[0].Name != "orders-dlq" || - result.Data[0].Depth != 42 || len(result.Data[0].SourceQueueARNs) != 1 || result.Data[0].SourceQueueCount != 1 || + if result.SchemaVersion != "v1" || len(result.Data) != 2 || result.Data[0].Name != "orders-dlq" || + result.Data[0].Depth != 42 || len(result.Data[0].SourceQueueARNs) != 2 || result.Data[0].SourceQueueCount != 2 || + result.Data[1].Name != "idle" || result.Data[1].SourceQueueARNs == nil || len(result.Data[1].SourceQueueARNs) != 0 || result.Data[1].SourceQueueCount != 0 || result.Warnings == nil || !result.Pagination.Complete { t.Fatalf("unexpected result: %+v", result) } } +func TestSQSQueuesReturnsLoaderErrorWithoutJSON(t *testing.T) { + original := loadSQSQueues + defer func() { loadSQSQueues = original }() + wantErr := errors.New("queue lookup failed") + loadSQSQueues = func(context.Context) ([]awsservice.SQSQueue, error) { return nil, wantErr } + cmd := NewRootCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"resources", "sqs-queues", "--json"}) + if err := cmd.Execute(); !errors.Is(err, wantErr) { + t.Fatalf("expected loader error, got %v", err) + } + if output.Len() != 0 { + t.Fatalf("expected no success envelope, got %s", output.String()) + } +} + func TestCloudTrailEventsReportsCapAsIncomplete(t *testing.T) { original := loadCloudTrailEvents defer func() { loadCloudTrailEvents = original }() diff --git a/internal/services/aws/sqs.go b/internal/services/aws/sqs.go index 04d864d0..d3a10073 100644 --- a/internal/services/aws/sqs.go +++ b/internal/services/aws/sqs.go @@ -109,7 +109,8 @@ func (r *AwsRepository) ListQueues(ctx context.Context) ([]SQSQueue, error) { } } for i := range queues { - queues[i].SourceQueueARNs = sources[queues[i].ARN] + queues[i].SourceQueueARNs = append([]string{}, sources[queues[i].ARN]...) + sort.Strings(queues[i].SourceQueueARNs) queues[i].SourceQueueCount = len(queues[i].SourceQueueARNs) } diff --git a/internal/services/aws/sqs_test.go b/internal/services/aws/sqs_test.go index f9a5926e..0fba095b 100644 --- a/internal/services/aws/sqs_test.go +++ b/internal/services/aws/sqs_test.go @@ -3,6 +3,7 @@ package aws import ( "context" "errors" + "slices" "strings" "testing" @@ -61,6 +62,9 @@ func TestListQueuesSortsByBacklogAndResolvesDLQ(t *testing.T) { map[string]string{"RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:1:orders-dlq","maxReceiveCount":3}`}), "https://sqs.us-east-1.amazonaws.com/1/orders-dlq": sqsQueueAttrs( "arn:aws:sqs:us-east-1:1:orders-dlq", "120", nil), + "https://sqs.us-east-1.amazonaws.com/1/payments": sqsQueueAttrs( + "arn:aws:sqs:us-east-1:1:payments", "3", + map[string]string{"RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:1:orders-dlq","maxReceiveCount":5}`}), "https://sqs.us-east-1.amazonaws.com/1/idle": sqsQueueAttrs( "arn:aws:sqs:us-east-1:1:idle", "0", nil), } @@ -82,24 +86,35 @@ func TestListQueuesSortsByBacklogAndResolvesDLQ(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(queues) != 3 { - t.Fatalf("expected 3 queues, got %d", len(queues)) + if len(queues) != 4 { + t.Fatalf("expected 4 queues, got %d", len(queues)) } if queues[0].Name != "orders-dlq" || queues[0].Depth != 120 { t.Fatalf("expected deepest backlog first, got %+v", queues[0]) } - if !queues[0].IsDLQ() || queues[0].SourceQueueCount != 1 { - t.Fatalf("expected orders-dlq marked as DLQ with one source, got %+v", queues[0]) + if !queues[0].IsDLQ() || queues[0].SourceQueueCount != 2 { + t.Fatalf("expected orders-dlq marked as DLQ with two sources, got %+v", queues[0]) + } + wantSources := []string{"arn:aws:sqs:us-east-1:1:orders", "arn:aws:sqs:us-east-1:1:payments"} + if !slices.Equal(queues[0].SourceQueueARNs, wantSources) { + t.Fatalf("expected sorted source queue ARNs %v, got %v", wantSources, queues[0].SourceQueueARNs) } var orders SQSQueue + var idle SQSQueue for _, queue := range queues { if queue.Name == "orders" { orders = queue } + if queue.Name == "idle" { + idle = queue + } } if orders.DLQTargetARN != "arn:aws:sqs:us-east-1:1:orders-dlq" || orders.MaxReceiveCount != 3 { t.Fatalf("expected redrive policy parsed, got %+v", orders) } + if idle.SourceQueueARNs == nil || idle.SourceQueueCount != 0 { + t.Fatalf("expected empty source queue array, got %+v", idle) + } if !strings.Contains(queues[0].DisplayTitle(), "!") { t.Fatalf("expected DLQ marker in display title, got %q", queues[0].DisplayTitle()) }