diff --git a/README.md b/README.md index 8c2d4c3..554a9b6 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,10 @@ The `censeye` command allows you to perform a Censeye scan on a host. See the [c This is a WIP. See the [history command docs](./docs/commands/HISTORY.md) for more details. +### Tags + +The `tags` command lets you label and organize assets. You can manage tags themselves (`list`, `get`, `create`, `update`, `delete`), assign and unassign them on hosts, certificates, and web properties, and list what a tag is assigned to. Assets can be given as arguments, read from a file or STDIN, or selected in bulk by a CenQL query — bulk changes run as asynchronous jobs you can track, wait on, and cancel with `censys tags operations`. See the [tags command docs](./docs/commands/TAGS.md) for more details. + ### Other Commands - `$ censys org`: manage and view organization details. See the [org command docs](./docs/commands/ORG.md) for more details. diff --git a/cmd/cencli/e2e/fixtures/fixtures.go b/cmd/cencli/e2e/fixtures/fixtures.go index 581a898..8df5a6d 100644 --- a/cmd/cencli/e2e/fixtures/fixtures.go +++ b/cmd/cencli/e2e/fixtures/fixtures.go @@ -32,5 +32,6 @@ func Fixtures() map[string][]Fixture { "history": historyFixtures, "credits": creditsFixtures, "org": orgFixtures, + "tags": tagsFixtures, } } diff --git a/cmd/cencli/e2e/fixtures/golden/golden.go b/cmd/cencli/e2e/fixtures/golden/golden.go index ddda599..f5bfd05 100644 --- a/cmd/cencli/e2e/fixtures/golden/golden.go +++ b/cmd/cencli/e2e/fixtures/golden/golden.go @@ -29,4 +29,30 @@ var ( OrgCreditsHelpStdout []byte //go:embed org_help.out OrgHelpStdout []byte + //go:embed tags_help.out + TagsHelpStdout []byte + //go:embed tags_list_help.out + TagsListHelpStdout []byte + //go:embed tags_get_help.out + TagsGetHelpStdout []byte + //go:embed tags_create_help.out + TagsCreateHelpStdout []byte + //go:embed tags_update_help.out + TagsUpdateHelpStdout []byte + //go:embed tags_delete_help.out + TagsDeleteHelpStdout []byte + //go:embed tags_assign_help.out + TagsAssignHelpStdout []byte + //go:embed tags_unassign_help.out + TagsUnassignHelpStdout []byte + //go:embed tags_assignments_help.out + TagsAssignmentsHelpStdout []byte + //go:embed tags_operations_help.out + TagsOperationsHelpStdout []byte + //go:embed tags_operations_list_help.out + TagsOperationsListHelpStdout []byte + //go:embed tags_operations_get_help.out + TagsOperationsGetHelpStdout []byte + //go:embed tags_operations_cancel_help.out + TagsOperationsCancelHelpStdout []byte ) diff --git a/cmd/cencli/e2e/fixtures/golden/root.out b/cmd/cencli/e2e/fixtures/golden/root.out index bf79a00..63505b7 100644 --- a/cmd/cencli/e2e/fixtures/golden/root.out +++ b/cmd/cencli/e2e/fixtures/golden/root.out @@ -17,6 +17,7 @@ Available Commands: history Retrieve historical data for hosts, web properties, and certificates org Manage and view organization details search Execute a search query across Censys data + tags Manage tags and tag assignments for your organization version Print version information view Retrieve information about hosts, certificates, and web properties diff --git a/cmd/cencli/e2e/fixtures/golden/tags_assign_help.out b/cmd/cencli/e2e/fixtures/golden/tags_assign_help.out new file mode 100644 index 0000000..abc5ed4 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_assign_help.out @@ -0,0 +1,46 @@ +Assign a tag, by its name or UUID, to one or more assets (host IPs, certificate SHA-256 +fingerprints, or web property hostname:port). + +Assets can be passed as positional arguments or read from a file (or STDIN) with +--input-file. Assets of different types can be mixed in a single call. Each asset is +assigned independently: if one fails the rest still proceed, and the per-asset outcomes +are reported. + +Use --query instead to assign the tag to every asset matching a CenQL query. That starts +an asynchronous bulk job and reports the operation tracking it; the two input modes cannot +be combined. Bulk assignment always asks for confirmation unless --yes is set. + +Usage: + censys tags assign [asset...] [flags] + +Examples: + censys tags assign + censys tags assign + censys tags assign + censys tags assign + censys tags assign # asset types can be mixed + censys tags assign --input-file + censys tags assign --input-file - # read assets from STDIN + censys tags assign --query 'host.services.port: 22' # assign every matching asset + censys tags assign --query 'host.services.port: 22' --max-assets 1000 + censys tags assign --query 'host.services.port: 22' --wait # poll until the job finishes + +Flags: + -h, --help help for assign + -i, --input-file string file to read the assets from (or - for STDIN). Overrides positional asset arguments. + --max-assets int cap the number of assets a bulk job tags (requires --query). The effective cap is the smaller of this and your plan's tag asset limit. + -o, --org-id string organization ID to target (personal access tokens only) + --query string CenQL query selecting the assets to tag. Starts a bulk job instead of assigning explicit assets. + --timeout string how long to wait before giving up (requires --wait) - use 0 for no limit (default "30m0s") + -w, --wait poll the bulk job until it reaches a final status (requires --query) + -y, --yes skip the confirmation prompt (requires --query) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_assignments_help.out b/cmd/cencli/e2e/fixtures/golden/tags_assignments_help.out new file mode 100644 index 0000000..4b1dc3d --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_assignments_help.out @@ -0,0 +1,37 @@ +List the assets a tag, given by its name or UUID, is assigned to. + +Results can be filtered by asset, asset type, creator, and creation time. Use --streaming +to emit each assignment as NDJSON as it is fetched. + +Usage: + censys tags assignments [flags] + +Examples: + censys tags assignments my-tag # List a tag's assignments + censys tags assignments my-tag --asset-type host # Only host assignments + censys tags assignments my-tag --asset # Check whether one asset is assigned + censys tags assignments my-tag --created-after 2025-01-01T00:00:00Z # Only recent assignments + censys tags assignments my-tag --max-pages -1 # Fetch every page + censys tags assignments my-tag --streaming # Emit NDJSON as assignments are fetched + +Flags: + --asset strings filter by one asset (host IP, certificate SHA-256 fingerprint, or web property hostname:port) - giving more than one is an error + --asset-type string filter by asset type (host, web_property, certificate) + --created-after string only assignments created after this time + --created-before string only assignments created before this time + --created-by string filter by the UUID of the assignment's creator + -h, --help help for assignments + -p, --max-pages int maximum number of pages to fetch (-1 for all pages) (default 1) + --order-by string sort order (create_time_asc, create_time_desc) + -o, --org-id string organization ID to target (personal access tokens only) + -n, --page-size int number of assignments to return per page (default 100) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_create_help.out b/cmd/cencli/e2e/fixtures/golden/tags_create_help.out new file mode 100644 index 0000000..bb1753c --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_create_help.out @@ -0,0 +1,28 @@ +Create a new tag with the given name. + +Tag names must be unique within an organization. New tags are private by default; use +--privacy shared to make a tag visible to all organization members. + +Usage: + censys tags create [flags] + +Examples: + censys tags create my-tag # Create a private tag + censys tags create my-tag --privacy shared # Create a shared tag + censys tags create my-tag --description "Assets flagged for review" # Create a tag with a description + +Flags: + --description string a human-readable description of the tag + -h, --help help for create + -o, --org-id string organization ID to target (personal access tokens only) + --privacy string tag visibility (private, shared) (default "private") + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_delete_help.out b/cmd/cencli/e2e/fixtures/golden/tags_delete_help.out new file mode 100644 index 0000000..b7948d3 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_delete_help.out @@ -0,0 +1,26 @@ +Delete a tag by its name or UUID. This cannot be undone. + +You are prompted to confirm before the tag is deleted. Use --yes to skip the prompt; in a +non-interactive terminal --yes is required. + +Usage: + censys tags delete [flags] + +Examples: + censys tags delete my-tag # Delete a tag by name (prompts for confirmation) + censys tags delete my-tag --yes # Delete without confirming + +Flags: + -h, --help help for delete + -o, --org-id string organization ID to target (personal access tokens only) + -y, --yes skip the confirmation prompt + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_get_help.out b/cmd/cencli/e2e/fixtures/golden/tags_get_help.out new file mode 100644 index 0000000..4b7228b --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_get_help.out @@ -0,0 +1,30 @@ +Retrieve a single tag by its name or UUID. + +Tag names are unique within an organization, so a name and its ID can be used +interchangeably. + +The tag payload carries no assignment count, so counting the assets it is assigned to +costs a second request. That count is always reported; if only the count fails, the tag is +still printed and the count error follows it. + +Usage: + censys tags get [flags] + +Examples: + censys tags get my-tag # Get a tag by name + censys tags get # Get a tag by UUID + censys tags get my-tag --output-format json # Output as JSON + +Flags: + -h, --help help for get + -o, --org-id string organization ID to target (personal access tokens only) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_help.out b/cmd/cencli/e2e/fixtures/golden/tags_help.out new file mode 100644 index 0000000..875a763 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_help.out @@ -0,0 +1,32 @@ +Manage tags and tag assignments for your organization. + +Use --org-id on any subcommand to target a different organization; otherwise your stored +organization ID is used. + +Usage: + censys tags [flags] + censys tags [command] + +Available Commands: + assign Assign a tag to one or more assets + assignments List the assets a tag is assigned to + create Create a new tag + delete Delete a tag + get Retrieve a single tag by name or ID + list List all tags + operations Track the asynchronous jobs created by bulk tag operations + unassign Unassign a tag from one or more assets + update Update an existing tag + +Flags: + -h, --help help for tags + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_list_help.out b/cmd/cencli/e2e/fixtures/golden/tags_list_help.out new file mode 100644 index 0000000..58e6ecd --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_list_help.out @@ -0,0 +1,33 @@ +List all tags in your organization. + +Results can be filtered by privacy, name, and creator, and sorted by various fields. + +Usage: + censys tags list [flags] + +Examples: + censys tags list # List all tags + censys tags list --privacy shared # List only shared tags + censys tags list --name my-tag # Filter by exact name + censys tags list --order-by name_desc # Sort by name descending + censys tags list --output-format json # Output as JSON + +Flags: + --created-by string filter by the UUID of the tag's creator + -h, --help help for list + -p, --max-pages int maximum number of pages to fetch (-1 for all pages) (default 1) + --name string filter by exact tag name + --order-by string sort order (name_asc, name_desc, created_at_asc, created_at_desc, updated_at_asc, updated_at_desc) + -o, --org-id string organization ID to target (personal access tokens only) + -n, --page-size int number of tags to return per page (default 100) + --privacy string filter by privacy (private, shared) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_operations_cancel_help.out b/cmd/cencli/e2e/fixtures/golden/tags_operations_cancel_help.out new file mode 100644 index 0000000..419f8bb --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_operations_cancel_help.out @@ -0,0 +1,26 @@ +Cancel a running bulk tag operation, identified by the tag it belongs to, given by name or +UUID, and the operation's UUID. + +Cancelling stops the job from processing any more assets; it does not undo the assignments +it has already made or removed. An operation that has already finished cannot be +cancelled. + +Usage: + censys tags operations cancel [flags] + +Examples: + censys tags operations cancel my-tag # Stop a running bulk job + +Flags: + -h, --help help for cancel + -o, --org-id string organization ID to target (personal access tokens only) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_operations_get_help.out b/cmd/cencli/e2e/fixtures/golden/tags_operations_get_help.out new file mode 100644 index 0000000..cba6ef1 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_operations_get_help.out @@ -0,0 +1,30 @@ +Retrieve a single bulk tag operation by the tag it belongs to, given by name or UUID, and +the operation's UUID. + +Use --wait to poll until the operation finishes. Waiting exits non-zero if the operation +ends up failed or cancelled; without --wait the command simply reports the current status +and exits 0. + +Usage: + censys tags operations get [flags] + +Examples: + censys tags operations get my-tag # Show an operation's current status + censys tags operations get my-tag --wait # Poll until the operation finishes + censys tags operations get my-tag --wait --timeout 5m # Give up waiting after 5 minutes + +Flags: + -h, --help help for get + -o, --org-id string organization ID to target (personal access tokens only) + --timeout string how long to wait before giving up (requires --wait) - use 0 for no limit (default "30m0s") + -w, --wait poll until the operation reaches a final status + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_operations_help.out b/cmd/cencli/e2e/fixtures/golden/tags_operations_help.out new file mode 100644 index 0000000..2988a0c --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_operations_help.out @@ -0,0 +1,26 @@ +Track the asynchronous jobs created by bulk tag operations. + +Bulk assign and unassign submit a job rather than acting immediately; these commands list +those jobs, inspect a single one, and cancel one that is still running. + +Usage: + censys tags operations [flags] + censys tags operations [command] + +Available Commands: + cancel Cancel a running bulk tag operation + get Retrieve a single bulk tag operation + list List bulk tag operations + +Flags: + -h, --help help for operations + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_operations_list_help.out b/cmd/cencli/e2e/fixtures/golden/tags_operations_list_help.out new file mode 100644 index 0000000..c61f5bb --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_operations_list_help.out @@ -0,0 +1,33 @@ +List the asynchronous jobs created by bulk tag operations. + +Given a tag, by its name or UUID, only that tag's operations are listed; omit it to list +operations across every tag in the organization. + +Usage: + censys tags operations list [] [flags] + +Examples: + censys tags operations list # List operations across every tag + censys tags operations list my-tag # List one tag's operations + censys tags operations list my-tag --status running # Only operations still in flight + censys tags operations list --type bulk_delete # Only bulk unassign jobs + censys tags operations list --max-pages -1 # Fetch every page + +Flags: + -h, --help help for list + -p, --max-pages int maximum number of pages to fetch (-1 for all pages) (default 1) + --order-by string sort order (create_time_asc, create_time_desc) + -o, --org-id string organization ID to target (personal access tokens only) + -n, --page-size int number of operations to return per page (default 100) + --status string filter by status (pending, running, succeeded, limit_reached, failed, cancelled) + --type string filter by operation type (bulk_create, bulk_delete) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_unassign_help.out b/cmd/cencli/e2e/fixtures/golden/tags_unassign_help.out new file mode 100644 index 0000000..bc6c0ac --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_unassign_help.out @@ -0,0 +1,49 @@ +Unassign a tag, by its name or UUID, from one or more assets (host IPs, certificate +SHA-256 fingerprints, or web property hostname:port). + +Assets can be passed as positional arguments or read from a file (or STDIN) with +--input-file. Assets of different types can be mixed in a single call. Each asset is +unassigned independently: if one fails the rest still proceed, and the per-asset outcomes +are reported. + +Use --all instead to remove every one of the tag's assignments, or +--created-before/--created-after to remove only those created in a time window. Either +form starts an asynchronous bulk job and reports the operation tracking it; it cannot be +combined with explicit assets, and --all cannot be narrowed by a time filter. Bulk +unassignment always asks for confirmation unless --yes is set. + +Usage: + censys tags unassign [asset...] [flags] + +Examples: + censys tags unassign + censys tags unassign + censys tags unassign + censys tags unassign + censys tags unassign # asset types can be mixed + censys tags unassign --input-file + censys tags unassign --input-file - # read assets from STDIN + censys tags unassign --all # remove every one of the tag's assignments + censys tags unassign --created-before 2026-01-01T00:00:00Z # only assignments made before then + censys tags unassign --all --wait # poll until the job finishes + +Flags: + --all remove every one of the tag's assignments. Starts a bulk job instead of unassigning explicit assets. + --created-after string only unassign assignments created after this time. Starts a bulk job. + --created-before string only unassign assignments created before this time. Starts a bulk job. + -h, --help help for unassign + -i, --input-file string file to read the assets from (or - for STDIN). Overrides positional asset arguments. + -o, --org-id string organization ID to target (personal access tokens only) + --timeout string how long to wait before giving up (requires --wait) - use 0 for no limit (default "30m0s") + -w, --wait poll the bulk job until it reaches a final status (requires --all or a time filter) + -y, --yes skip the confirmation prompt (requires --all or a time filter) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/tags_update_help.out b/cmd/cencli/e2e/fixtures/golden/tags_update_help.out new file mode 100644 index 0000000..255dfa7 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/tags_update_help.out @@ -0,0 +1,31 @@ +Update an existing tag by its name or UUID. + +At least one mutation flag is required. Use --clear-description to remove a tag's +description; it cannot be combined with --description. + +Usage: + censys tags update [flags] + +Examples: + censys tags update my-tag --description "Assets flagged for review" # Set a description + censys tags update my-tag --privacy shared # Make a tag visible to the organization + censys tags update my-tag --name renamed-tag # Rename a tag + censys tags update my-tag --clear-description # Remove the description + +Flags: + --clear-description remove the tag's description + --description string a new description for the tag + -h, --help help for update + --name string a new name for the tag + -o, --org-id string organization ID to target (personal access tokens only) + --privacy string tag visibility (private, shared) + +Global Flags: + --debug enable debug logging + --no-color disable ANSI colors and styles + --no-spinner disable spinner during operations + -O, --output-format string output format (json|yaml|tree|short|template) (default "short") + -q, --quiet suppress non-essential output + -S, --streaming enable streaming output mode (NDJSON) for commands that support it + --timeout-http duration per-request timeout for HTTP requests (e.g. 10s, 1m) - use 0 to disable + diff --git a/cmd/cencli/e2e/fixtures/golden/update.sh b/cmd/cencli/e2e/fixtures/golden/update.sh index 2ee9d20..55b1110 100755 --- a/cmd/cencli/e2e/fixtures/golden/update.sh +++ b/cmd/cencli/e2e/fixtures/golden/update.sh @@ -26,6 +26,19 @@ echo "Updating golden fixtures..." "$BINARY" org members --help > org_members_help.out "$BINARY" org credits --help > org_credits_help.out "$BINARY" org --help > org_help.out +"$BINARY" tags --help > tags_help.out +"$BINARY" tags list --help > tags_list_help.out +"$BINARY" tags get --help > tags_get_help.out +"$BINARY" tags create --help > tags_create_help.out +"$BINARY" tags update --help > tags_update_help.out +"$BINARY" tags delete --help > tags_delete_help.out +"$BINARY" tags assign --help > tags_assign_help.out +"$BINARY" tags unassign --help > tags_unassign_help.out +"$BINARY" tags assignments --help > tags_assignments_help.out +"$BINARY" tags operations --help > tags_operations_help.out +"$BINARY" tags operations list --help > tags_operations_list_help.out +"$BINARY" tags operations get --help > tags_operations_get_help.out +"$BINARY" tags operations cancel --help > tags_operations_cancel_help.out "$BINARY" > root.out echo "✅ All golden fixtures updated" diff --git a/cmd/cencli/e2e/fixtures/tags.go b/cmd/cencli/e2e/fixtures/tags.go new file mode 100644 index 0000000..cea502d --- /dev/null +++ b/cmd/cencli/e2e/fixtures/tags.go @@ -0,0 +1,829 @@ +package fixtures + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/censys/cencli/cmd/cencli/e2e/fixtures/golden" + "github.com/censys/cencli/internal/app/tags" +) + +var tagsFixtures = []Fixture{ + { + Name: "help", + Args: []string{"--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsHelpStdout, stdout, 0) + }, + }, + { + Name: "help with no args", + Args: []string{}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsHelpStdout, stdout, 0) + }, + }, + // ========== list subcommand ========== + { + Name: "list help", + Args: []string{"list", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsListHelpStdout, stdout, 0) + }, + }, + { + Name: "list invalid max-pages", + Args: []string{"list", "--max-pages", "0"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "max-pages") + }, + }, + { + Name: "list basic", + Args: []string{"list", "--output-format", "json"}, + ExitCode: 0, + Timeout: 10 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[[]tags.Tag](t, stdout) + for _, tag := range data { + assert.NotEmpty(t, tag.ID) + assert.NotEmpty(t, tag.Name) + assert.NotEmpty(t, tag.Privacy) + } + }, + }, + // ========== get subcommand ========== + { + Name: "get help", + Args: []string{"get", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsGetHelpStdout, stdout, 0) + }, + }, + { + Name: "get missing arg", + Args: []string{"get"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 1 arg") + }, + }, + { + // Exercises the live GetTag endpoint + API-error translation without + // depending on org-specific tag data. A random UUID never maps to a real + // tag; the API masks resource existence and returns 403 Permission denied + // (not 404), so that is the expected error here. + Name: "get empty tag id", + Args: []string{"get", ""}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "a tag name or ID is required") + }, + }, + { + Name: "get forbidden for unknown id", + Args: []string{"get", "00000000-0000-4000-8000-000000000000"}, + ExitCode: 1, + Timeout: 10 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "Permission denied") + }, + }, + // ========== create subcommand ========== + // No live create fixture: create is a non-idempotent write with no + // deterministic teardown, and --privacy validation runs after auth. Both are + // covered by unit tests (internal/app/tags, internal/command/tags). + { + Name: "create help", + Args: []string{"create", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsCreateHelpStdout, stdout, 0) + }, + }, + { + Name: "create missing arg", + Args: []string{"create"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 1 arg") + }, + }, + // ========== update subcommand ========== + // No live update fixture: update is a non-idempotent write with no + // deterministic fixture, and --privacy validation runs after auth. Covered + // by unit tests (internal/app/tags, internal/command/tags). + { + Name: "update help", + Args: []string{"update", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsUpdateHelpStdout, stdout, 0) + }, + }, + { + Name: "update missing arg", + Args: []string{"update"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 1 arg") + }, + }, + { + Name: "update nothing to update", + Args: []string{"update", "my-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "no fields to update") + }, + }, + { + Name: "update description conflict", + Args: []string{"update", "my-tag", "--description", "foo", "--clear-description"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "cannot be used together") + }, + }, + // ========== delete subcommand ========== + { + Name: "delete help", + Args: []string{"delete", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsDeleteHelpStdout, stdout, 0) + }, + }, + { + Name: "delete missing arg", + Args: []string{"delete"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 1 arg") + }, + }, + { + // A non-interactive terminal (piped stdin) without --yes cannot prompt, + // so the command refuses before any auth is required. + Name: "delete confirmation required in non-interactive terminal", + Args: []string{"delete", "some-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "confirmation required") + }, + }, + { + // An empty tag id must be rejected up front, never resolved to an + // arbitrary tag and deleted. + Name: "delete empty tag id", + Args: []string{"delete", ""}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "a tag name or ID is required") + }, + }, + // ========== assign subcommand ========== + // No live assign fixture: assign is a non-idempotent write with no + // deterministic teardown. Covered by unit tests (internal/app/tags, + // internal/command/tags). + { + Name: "assign help", + Args: []string{"assign", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsAssignHelpStdout, stdout, 0) + }, + }, + { + Name: "assign missing arg", + Args: []string{"assign"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "requires at least 1 arg") + }, + }, + { + // A tag with no assets and no --input-file has nothing to act on. + Name: "assign no assets", + Args: []string{"assign", "my-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "at least one asset") + }, + }, + { + // An unparseable asset is rejected before anything is sent. + Name: "assign unknown asset", + Args: []string{"assign", "my-tag", "not-an-asset"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-an-asset") + }, + }, + { + Name: "assign empty tag id", + Args: []string{"assign", "", "8.8.8.8"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "a tag name or ID is required") + }, + }, + // No live bulk-assign fixture either: a bulk job mutates at scale and cannot + // be undone deterministically. These cover what the command rejects before + // any request is sent. + { + // Bulk is never inferred, so the two input modes cannot be mixed. + Name: "assign query with explicit assets", + Args: []string{"assign", "my-tag", "8.8.8.8", "--query", "host.services.port: 22"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "cannot be combined with explicit assets") + }, + }, + { + Name: "assign query with input file", + Args: []string{"assign", "my-tag", "--input-file", "-", "--query", "host.services.port: 22"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "cannot be combined with explicit assets") + }, + }, + { + // A blank query would match nothing; rejected before it can prompt. + Name: "assign empty query", + Args: []string{"assign", "my-tag", "--query", " "}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--query must not be empty") + }, + }, + { + Name: "assign max assets without query", + Args: []string{"assign", "my-tag", "8.8.8.8", "--max-assets", "10"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--max-assets only applies to a bulk assignment") + }, + }, + { + Name: "assign wait without query", + Args: []string{"assign", "my-tag", "8.8.8.8", "--wait"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--wait only applies to a bulk assignment") + }, + }, + { + Name: "assign timeout without wait", + Args: []string{"assign", "my-tag", "--query", "host.services.port: 22", "--timeout", "5m"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--timeout only applies while polling") + }, + }, + { + // e2e runs without a TTY, so a bulk assignment cannot prompt and must + // refuse rather than submit silently. + Name: "assign query non-interactive without yes", + Args: []string{"assign", "my-tag", "--query", "host.services.port: 22"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "confirmation required") + }, + }, + // ========== unassign subcommand ========== + // No live unassign fixture: unassign is a non-idempotent write with no + // deterministic teardown. Covered by unit tests (internal/app/tags, + // internal/command/tags). + { + Name: "unassign help", + Args: []string{"unassign", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsUnassignHelpStdout, stdout, 0) + }, + }, + { + Name: "unassign missing arg", + Args: []string{"unassign"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "requires at least 1 arg") + }, + }, + { + // A tag with no assets and no --input-file has nothing to act on. + Name: "unassign no assets", + Args: []string{"unassign", "my-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "at least one asset") + }, + }, + { + // An unparseable asset is rejected before anything is sent. + Name: "unassign unknown asset", + Args: []string{"unassign", "my-tag", "not-an-asset"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-an-asset") + }, + }, + { + Name: "unassign empty tag id", + Args: []string{"unassign", "", "8.8.8.8"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "a tag name or ID is required") + }, + }, + { + // Explicit unassignment never prompts, so --yes has nothing to skip and is + // rejected outside bulk mode rather than silently ignored. + Name: "unassign yes without bulk mode", + Args: []string{"unassign", "my-tag", "8.8.8.8", "1.1.1.1", "--yes"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--yes only applies to a bulk unassignment") + }, + }, + // No live bulk-unassign fixture either: a bulk job mutates at scale and cannot + // be undone deterministically. These cover what the command rejects before any + // request is sent. + { + // Bulk is never inferred, so the two input modes cannot be mixed. + Name: "unassign all with explicit assets", + Args: []string{"unassign", "my-tag", "8.8.8.8", "--all"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "cannot be combined with explicit assets") + }, + }, + { + Name: "unassign all with input file", + Args: []string{"unassign", "my-tag", "--input-file", "-", "--all"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "cannot be combined with explicit assets") + }, + }, + { + // A time filter alone selects bulk mode, so it conflicts the same way. + Name: "unassign time filter with explicit assets", + Args: []string{"unassign", "my-tag", "8.8.8.8", "--created-before", "2026-01-01T00:00:00Z"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "cannot be combined with explicit assets") + }, + }, + { + // --all already means every assignment, so narrowing it contradicts itself. + Name: "unassign all with created-before", + Args: []string{"unassign", "my-tag", "--all", "--created-before", "2026-01-01T00:00:00Z"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--all unassigns every assignment") + }, + }, + { + Name: "unassign all with created-after", + Args: []string{"unassign", "my-tag", "--all", "--created-after", "2026-01-01T00:00:00Z"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--all unassigns every assignment") + }, + }, + { + Name: "unassign wait without bulk mode", + Args: []string{"unassign", "my-tag", "8.8.8.8", "--wait"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--wait only applies to a bulk unassignment") + }, + }, + { + Name: "unassign timeout without wait", + Args: []string{"unassign", "my-tag", "--all", "--timeout", "5m"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--timeout only applies while polling") + }, + }, + { + // An inverted window matches nothing, which would look like a wipe that + // found nothing rather than a mistake. + Name: "unassign inverted time window", + Args: []string{ + "unassign", "my-tag", + "--created-before", "2020-01-01T00:00:00Z", + "--created-after", "2026-01-01T00:00:00Z", + "--yes", + }, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "created-before must be after created-after") + }, + }, + { + // e2e runs without a TTY, so a bulk removal cannot prompt and must refuse + // rather than wipe a tag's assignments silently. + Name: "unassign all non-interactive without yes", + Args: []string{"unassign", "my-tag", "--all"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "confirmation required") + }, + }, + // ========== assignments subcommand ========== + // No live assignments fixture: the org has no tag with a deterministic set of + // assignments to assert against. Covered by unit tests (internal/app/tags, + // internal/command/tags) and verified manually against the live API. + { + Name: "assignments help", + Args: []string{"assignments", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsAssignmentsHelpStdout, stdout, 0) + }, + }, + { + Name: "assignments missing arg", + Args: []string{"assignments"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 1 arg") + }, + }, + { + Name: "assignments empty tag id", + Args: []string{"assignments", ""}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "a tag name or ID is required") + }, + }, + { + // An unparseable --asset filter is rejected before anything is sent. + Name: "assignments unknown asset filter", + Args: []string{"assignments", "my-tag", "--asset", "not-an-asset"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-an-asset") + }, + }, + { + // The endpoint filters on one asset, so a list is a usage error. + Name: "assignments multiple asset filters", + Args: []string{"assignments", "my-tag", "--asset", "8.8.8.8,1.1.1.1"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "only 1") + }, + }, + { + // The same mistake spelled as a repeated flag, which used to take the + // last value and exit 0. + Name: "assignments repeated asset filter", + Args: []string{"assignments", "my-tag", "--asset", "8.8.8.8", "--asset", "1.1.1.1"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "2 assets provided, only 1") + }, + }, + { + // Page sizes above the documented maximum never reach the API. + Name: "assignments page-size above maximum", + Args: []string{"assignments", "my-tag", "--page-size", "1001"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "1000") + }, + }, + { + Name: "assignments invalid max-pages", + Args: []string{"assignments", "my-tag", "--max-pages", "0"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "max-pages") + }, + }, + { + Name: "assignments invalid timestamp", + Args: []string{"assignments", "my-tag", "--created-after", "yesterday"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "created-after") + }, + }, + { + // The API declares created_by as a UUID and 422s on anything else. + Name: "assignments non-uuid created-by", + Args: []string{"assignments", "my-tag", "--created-by", "not-a-uuid"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-a-uuid") + }, + }, + { + Name: "list non-uuid created-by", + Args: []string{"list", "--created-by", "not-a-uuid"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-a-uuid") + }, + }, + + // ========== operations subcommand ========== + { + Name: "operations help", + Args: []string{"operations", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsOperationsHelpStdout, stdout, 0) + }, + }, + { + Name: "operations list help", + Args: []string{"operations", "list", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsOperationsListHelpStdout, stdout, 0) + }, + }, + { + Name: "operations get help", + Args: []string{"operations", "get", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsOperationsGetHelpStdout, stdout, 0) + }, + }, + { + Name: "operations cancel help", + Args: []string{"operations", "cancel", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.TagsOperationsCancelHelpStdout, stdout, 0) + }, + }, + { + // The parent lists nothing itself; subcommands do the work. + Name: "operations rejects a positional argument", + Args: []string{"operations", "my-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 0 arg") + }, + }, + { + Name: "operations list empty tag", + Args: []string{"operations", "list", " "}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "tag name or ID is required") + }, + }, + // No fixtures for invalid --status/--type/--order-by: those enums are checked + // in the service layer, and PreRun resolves the service (requiring auth) + // before Run reaches the validation, so without credentials they fail as + // "not configured" instead. Same gap as list's --order-by/--privacy; covered + // by the service unit tests in internal/app/tags. + { + Name: "operations list invalid max-pages", + Args: []string{"operations", "list", "--max-pages", "0"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "max-pages") + }, + }, + { + // operation_id is format:uuid, so a bad one never reaches the API. + Name: "operations get non-uuid operation id", + Args: []string{"operations", "get", "my-tag", "not-a-uuid"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-a-uuid") + }, + }, + { + Name: "operations get missing operation id", + Args: []string{"operations", "get", "my-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 2 arg") + }, + }, + { + // A timeout that silently does nothing would be a dead flag. + Name: "operations get timeout without wait", + Args: []string{"operations", "get", "my-tag", "d421a231-eb5e-4927-a0be-8aa749eb731c", "--timeout", "5m"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "--timeout only applies while polling") + }, + }, + // No live cancel fixture: cancelling needs a real in-flight bulk job, and this + // suite never starts one. Covered by unit tests (internal/app/tags, + // internal/command/tags). + { + Name: "operations cancel non-uuid operation id", + Args: []string{"operations", "cancel", "my-tag", "not-a-uuid"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "not-a-uuid") + }, + }, + { + Name: "operations cancel missing operation id", + Args: []string{"operations", "cancel", "my-tag"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "accepts 2 arg") + }, + }, + { + Name: "operations cancel empty tag", + Args: []string{"operations", "cancel", " ", "d421a231-eb5e-4927-a0be-8aa749eb731c"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "tag name or ID is required") + }, + }, + { + // Cancelling does not confirm, so --yes is not a flag here at all. + Name: "operations cancel rejects yes", + Args: []string{"operations", "cancel", "my-tag", "d421a231-eb5e-4927-a0be-8aa749eb731c", "--yes"}, + ExitCode: 2, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assert.Contains(t, string(stderr), "unknown flag: --yes") + }, + }, + { + // Read-only and safe to run live; the org may legitimately have none. + Name: "operations list basic", + Args: []string{"operations", "list", "--output-format", "json"}, + ExitCode: 0, + Timeout: 10 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[[]tags.TagOperation](t, stdout) + for _, op := range data { + assert.NotEmpty(t, op.ID) + assert.NotEmpty(t, op.TagID) + assert.Contains(t, []string{"bulk_create", "bulk_delete"}, op.Type) + assert.Contains(t, + []string{"pending", "running", "succeeded", "limit_reached", "failed", "cancelled"}, + op.Status) + } + }, + }, +} diff --git a/docs/commands/TAGS.md b/docs/commands/TAGS.md new file mode 100644 index 0000000..69277a3 --- /dev/null +++ b/docs/commands/TAGS.md @@ -0,0 +1,518 @@ +# Tags Command + +The `tags` command manages tags and tag assignments for your organization. Tags let you label and organize assets (hosts, certificates, and web properties) for tracking and filtering. + +Running `censys tags` without a subcommand prints help. + +## Usage + +```bash +$ censys tags list # list all tags +$ censys tags get my-tag # show one tag +$ censys tags create my-tag # create a tag +$ censys tags update my-tag --privacy shared # change a tag +$ censys tags delete my-tag # delete a tag + +$ censys tags assign my-tag 8.8.8.8 # tag an asset +$ censys tags unassign my-tag 8.8.8.8 # untag an asset +$ censys tags assignments my-tag # list what a tag is assigned to + +$ censys tags operations list # list bulk jobs +$ censys tags operations get my-tag # inspect one bulk job +$ censys tags operations cancel my-tag # stop a running bulk job +``` + +## Organization Context + +Every `tags` subcommand accepts **`--org-id`, `-o`** (type `string`, UUID format). Which organization the commands act on depends on how you authenticated: + +- **Personal access token** — a PAT is not organization-scoped, so you choose: the stored organization ID by default, or `--org-id` per subcommand. To store a default, run `censys config org-id add` (see the [config command docs](./CONFIG.md)). +- **OAuth login (`censys auth login`)** — the organization is fixed by what that login was authorized for. Stored organization IDs are ignored and `--org-id` fails with an error; run `censys auth logout` and log in again to target a different organization. See [Organization context](AUTH.md#organization-context). + +To see global flags and how they affect these commands, see the [global configuration docs](../GLOBAL_CONFIGURATION.md). + +## Tag and Asset Identifiers + +**Tags** are identified by **name or UUID**, interchangeably — tag names are unique within an organization. `tags get` hands the identifier straight to the API, which resolves names itself. Every other command needs the tag's UUID, so passing a name costs one extra lookup to resolve it; passing a UUID never does. + +An identifier that looks like a UUID is treated as an **ID first**. If no tag has that ID, it is then tried as a **name** — so a tag whose name happens to be UUID-shaped still works everywhere, at the cost of one extra lookup once the ID reading has failed. Where both readings exist, the ID wins. + +**Assets** are identified by type: + +| Asset type | Identifier | Example | +| ------------ | ----------------------------------------- | ----------------- | +| Host | IPv4 or IPv6 address | `8.8.8.8` | +| Web property | `hostname:port` | `example.com:443` | +| Certificate | SHA-256 fingerprint (64 hex characters) | — | + +The type is detected from the format, the same way [`view`](VIEW.md#asset-type-detection) does it. Asset types **can be mixed** in a single `assign` or `unassign` call. + +## Commands + +### `tags list` + +List all tags in your organization. + +```bash +$ censys tags list # list all tags +$ censys tags list --privacy shared # only shared tags +$ censys tags list --name my-tag # filter by exact name +$ censys tags list --order-by name_desc # sort by name, descending +$ censys tags list --output-format json # output as JSON +``` + +Results are paginated. By default only the first page is fetched; use `--max-pages` to fetch more, or `-1` for all pages. When more tags exist than were fetched, the `short` output header reads `Tags (N of TOTAL)`. + +#### Flags + +**`--privacy`**: Filter by privacy setting. `private` tags are visible to and editable by organization admins only; `shared` tags are visible to all organization members. + +**Type:** `string` (`private` | `shared`) +**Default:** none (no privacy filter) + +**`--name`**: Filter by exact tag name. + +**Type:** `string` +**Default:** none + +**`--created-by`**: Filter by the UUID of the tag's creator. + +**Type:** `string` (UUID format) +**Default:** none + +**`--order-by`**: Sort order for the results. + +**Type:** `string` (`name_asc`, `name_desc`, `created_at_asc`, `created_at_desc`, `updated_at_asc`, `updated_at_desc`) +**Default:** none (the API's own ordering) + +**`--page-size`, `-n`**: Number of tags to return per page. The API caps this at 1000. + +**Type:** `integer` (1–1000) +**Default:** `100` + +**`--max-pages`, `-p`**: Maximum number of pages to fetch. Use `-1` to fetch all pages. + +**Type:** `integer` +**Default:** `1` + +### `tags get` + +Retrieve a single tag by its name or UUID. + +```bash +$ censys tags get my-tag # get a tag by name +$ censys tags get # get a tag by UUID +$ censys tags get my-tag --output-format json # output as JSON +``` + +The output includes `asset_count` — how many assets the tag is assigned to. The tag record itself carries no count, so `get` always makes a **second request** for it, using the UUID from the tag it just fetched. If only that second request fails, the tag is still printed and the count error is reported afterwards: a failed count never fails the command, and `asset_count` is then absent rather than `0`. + +#### Flags + +Only the global flags and `--org-id`. + +### `tags create` + +Create a new tag with the given name. Tag names must be unique within an organization. + +```bash +$ censys tags create my-tag # create a private tag +$ censys tags create my-tag --privacy shared # create a shared tag +$ censys tags create my-tag --description "Assets flagged for review" # create a tag with a description +``` + +#### Flags + +**`--privacy`**: Tag visibility. New tags are private by default; use `shared` to make a tag visible to all organization members. + +**Type:** `string` (`private` | `shared`) +**Default:** `private` + +**`--description`**: A human-readable description of the tag. + +**Type:** `string` +**Default:** none + +### `tags update` + +Update an existing tag by its name or UUID. **At least one mutation flag is required** — an update with nothing to change is rejected rather than sent. + +```bash +$ censys tags update my-tag --description "Assets flagged for review" # set a description +$ censys tags update my-tag --privacy shared # make a tag visible to the organization +$ censys tags update my-tag --name renamed-tag # rename a tag +$ censys tags update my-tag --clear-description # remove the description +``` + +#### Flags + +**`--name`**: A new name for the tag. + +**Type:** `string` +**Default:** none (name unchanged) + +**`--privacy`**: New tag visibility. + +**Type:** `string` (`private` | `shared`) +**Default:** none (privacy unchanged) + +**`--description`**: A new description for the tag. Cannot be combined with `--clear-description`. + +**Type:** `string` +**Default:** none (description unchanged) + +**`--clear-description`**: Remove the tag's description. Cannot be combined with `--description`. + +**Type:** `boolean` +**Default:** `false` + +### `tags delete` + +Delete a tag by its name or UUID. **This cannot be undone**, and it also removes all of the tag's assignments. + +```bash +$ censys tags delete my-tag # delete a tag by name (prompts for confirmation) +$ censys tags delete my-tag --yes # delete without confirming +``` + +You are prompted to confirm before the tag is deleted. In a non-interactive terminal there is nobody to prompt, so `--yes` is required — without it the command fails rather than deleting silently. + +#### Flags + +**`--yes`, `-y`**: Skip the confirmation prompt. + +**Type:** `boolean` +**Default:** `false` + +### `tags assign` + +Assign a tag to one or more assets. + +```bash +$ censys tags assign my-tag 8.8.8.8 # one asset +$ censys tags assign my-tag 8.8.8.8 1.1.1.1 # several assets +$ censys tags assign my-tag example.com:443 # a web property +$ censys tags assign my-tag 8.8.8.8 example.com:443 # asset types can be mixed +$ censys tags assign my-tag --input-file assets.txt # read assets from a file +$ cat assets.txt | censys tags assign my-tag --input-file - # read assets from STDIN +$ censys tags assign my-tag --query 'host.services.port: 22' # bulk: every matching asset +``` + +Positional assets may be separated by spaces or commas (`8.8.8.8,1.1.1.1`); a file supplies one asset per line. Assets are validated before anything is sent — if one identifier is unrecognized, the whole call is rejected and no assignment is made. + +Each asset is then assigned **independently, one request per asset**: if one fails the rest still proceed, and every per-asset outcome is reported. A run where some assets succeeded and some failed is a *partial success* — it prints a summary to stderr and still **exits 0**. A run where none succeeded **exits 1**, but still prints the same per-asset results first, so you can see which assets failed and why. + +Assigning a tag to an asset that already has it fails for that asset with an `already exists` error from the API. + +Passing `--query` switches to bulk mode; see [Bulk Operations](#bulk-operations). + +#### Flags + +**`--input-file`, `-i`**: File to read the assets from, one per line, or `-` for STDIN. **Overrides** positional asset arguments — if both are given, the file wins. + +**Type:** `string` (path, or `-`) +**Default:** none + +**`--query`**: A CenQL query selecting the assets to tag. Starts a bulk job instead of assigning explicit assets, and cannot be combined with explicit assets or `--input-file`. + +**Type:** `string` +**Default:** none + +**`--max-assets`**: Cap the number of assets a bulk job tags. Requires `--query`. A single bulk job tags at most **100,000 assets**, so the effective cap is the smallest of this flag, that ceiling, and your plan's tag asset limit — see [Bulk Operations](#bulk-operations). + +**Type:** `integer` (≥ 0; `0` means no explicit cap) +**Default:** none + +**`--wait`, `-w`**: Poll the bulk job until it reaches a final status. Requires `--query`. + +**Type:** `boolean` +**Default:** `false` + +**`--timeout`**: How long to wait before giving up. Requires `--wait`. Use `0` for no limit. + +**Type:** `string` (duration, e.g. `5m`, `1h`) +**Default:** `30m` + +**`--yes`, `-y`**: Skip the confirmation prompt. Requires `--query` — explicit assignment does not prompt, so `--yes` is rejected there rather than silently ignored. + +**Type:** `boolean` +**Default:** `false` + +### `tags unassign` + +Unassign a tag from one or more assets. + +```bash +$ censys tags unassign my-tag 8.8.8.8 # one asset +$ censys tags unassign my-tag 8.8.8.8 1.1.1.1 # several assets +$ censys tags unassign my-tag --input-file assets.txt # read assets from a file +$ censys tags unassign my-tag --all # bulk: every assignment +$ censys tags unassign my-tag --created-before 2026-01-01T00:00:00Z # bulk: a time window +``` + +Explicit unassignment mirrors `assign`: the same space- or comma-separated asset input, validated up front, then each asset unassigned independently with per-asset outcomes and partial-success semantics. + +Unassigning an asset the tag is **not** assigned to is reported as a failure for that asset, not silently ignored. This is deliberate — it surfaces typos instead of reporting success for an asset you never touched. + +Like `assign`, explicit unassignment never prompts — the assets were named on the command line. Only bulk mode confirms. + +Passing `--all` or a time filter switches to bulk mode; see [Bulk Operations](#bulk-operations). + +#### Flags + +**`--input-file`, `-i`**: File to read the assets from, one per line, or `-` for STDIN. **Overrides** positional asset arguments. + +**Type:** `string` (path, or `-`) +**Default:** none + +**`--all`**: Remove every one of the tag's assignments. Starts a bulk job. Cannot be combined with explicit assets, nor narrowed by a time filter. + +**Type:** `boolean` +**Default:** `false` + +**`--created-before`**: Only unassign assignments created before this time. Starts a bulk job. + +**Type:** `string` (RFC3339 timestamp) +**Default:** none + +**`--created-after`**: Only unassign assignments created after this time. Starts a bulk job. + +**Type:** `string` (RFC3339 timestamp) +**Default:** none + +**`--wait`, `-w`**: Poll the bulk job until it reaches a final status. Requires `--all` or a time filter. + +**Type:** `boolean` +**Default:** `false` + +**`--timeout`**: How long to wait before giving up. Requires `--wait`. Use `0` for no limit. + +**Type:** `string` (duration, e.g. `5m`, `1h`) +**Default:** `30m` + +**`--yes`, `-y`**: Skip the confirmation prompt. Requires `--all` or a time filter — explicit unassignment does not prompt, so `--yes` is rejected there rather than silently ignored. + +**Type:** `boolean` +**Default:** `false` + +### `tags assignments` + +List the assets a tag is assigned to. + +```bash +$ censys tags assignments my-tag # list a tag's assignments +$ censys tags assignments my-tag --asset-type host # only host assignments +$ censys tags assignments my-tag --asset 8.8.8.8 # check whether one asset is assigned +$ censys tags assignments my-tag --created-after 2025-01-01T00:00:00Z +$ censys tags assignments my-tag --max-pages -1 # fetch every page +$ censys tags assignments my-tag --streaming # emit NDJSON as assignments are fetched +``` + +This is the one `tags` command that supports **streaming**. `--streaming` / `-S` is a [global flag](../GLOBAL_CONFIGURATION.md), so it does not appear in this command's own flag list, but it is supported here: each assignment is emitted as NDJSON as it is fetched instead of being collected and rendered at the end. It cannot be combined with `--output-format`. + +#### Flags + +**`--asset`**: Filter by a single asset (host IP, certificate SHA-256 fingerprint, or web property `hostname:port`). Exactly one asset — this is a filter, not a list. + +**Type:** `string` +**Default:** none + +**`--asset-type`**: Filter by asset type. + +**Type:** `string` (`host` | `web_property` | `certificate`) +**Default:** none + +**`--created-by`**: Filter by the UUID of the assignment's creator. + +**Type:** `string` (UUID format) +**Default:** none + +**`--created-before`**: Only assignments created before this time. + +**Type:** `string` (RFC3339 timestamp) +**Default:** none + +**`--created-after`**: Only assignments created after this time. Must be earlier than `--created-before` if both are given. + +**Type:** `string` (RFC3339 timestamp) +**Default:** none + +**`--order-by`**: Sort order for the results. Note this is a **different set** from `tags list` — assignments sort only by creation time. + +**Type:** `string` (`create_time_asc` | `create_time_desc`) +**Default:** none (the API's own ordering) + +**`--page-size`, `-n`**: Number of assignments to return per page. The API caps this at 1000. + +**Type:** `integer` (1–1000) +**Default:** `100` + +**`--max-pages`, `-p`**: Maximum number of pages to fetch. Use `-1` to fetch all pages. + +**Type:** `integer` +**Default:** `1` + +### `tags operations list` + +List the asynchronous jobs created by bulk tag operations. Given a tag, only that tag's operations are listed; omit it to list operations across every tag in the organization. + +```bash +$ censys tags operations list # every tag's operations +$ censys tags operations list my-tag # one tag's operations +$ censys tags operations list my-tag --status running # only operations still in flight +$ censys tags operations list --type bulk_delete # only bulk unassign jobs +$ censys tags operations list --max-pages -1 # fetch every page +``` + +#### Flags + +**`--status`**: Filter by operation status. + +**Type:** `string` (`pending`, `running`, `succeeded`, `limit_reached`, `failed`, `cancelled`) +**Default:** none + +**`--type`**: Filter by operation type. `bulk_create` jobs come from `tags assign --query`; `bulk_delete` jobs come from bulk `tags unassign`. + +**Type:** `string` (`bulk_create` | `bulk_delete`) +**Default:** none + +**`--order-by`**: Sort order for the results. + +**Type:** `string` (`create_time_asc` | `create_time_desc`) +**Default:** none (the API's own ordering) + +**`--page-size`, `-n`**: Number of operations to return per page. + +**Type:** `integer` (1–1000) +**Default:** `100` + +**`--max-pages`, `-p`**: Maximum number of pages to fetch. Use `-1` to fetch all pages. + +**Type:** `integer` +**Default:** `1` + +### `tags operations get` + +Retrieve a single bulk tag operation, by the tag it belongs to and the operation's UUID. + +```bash +$ censys tags operations get my-tag # show current status +$ censys tags operations get my-tag --wait # poll until it finishes +$ censys tags operations get my-tag --wait --timeout 5m # give up waiting after 5 minutes +``` + +Without `--wait` this is a plain read: it reports whatever status the operation currently has and **exits 0**, even for a failed operation — reading a failed job is itself a successful read. Only `--wait` maps a terminal status onto the exit code (see [Bulk Operations](#bulk-operations)). + +Interrupting a wait (Ctrl-C) stops the polling, not the job. The command tells you so and prints the command to resume tracking. + +#### Flags + +**`--wait`, `-w`**: Poll until the operation reaches a final status. + +**Type:** `boolean` +**Default:** `false` + +**`--timeout`**: How long to wait before giving up. Requires `--wait`. Use `0` for no limit. + +**Type:** `string` (duration, e.g. `5m`, `1h`) +**Default:** `30m` + +### `tags operations cancel` + +Cancel a running bulk tag operation. + +```bash +$ censys tags operations cancel my-tag # stop a running bulk job +``` + +Cancelling stops the job from processing any more assets. **It is not a rollback** — assignments the job has already made or removed stay as they are. + +An operation that has already finished cannot be cancelled; the API rejects it with a `Tag operation not cancellable` conflict. A successful cancellation exits 0. + +This command does not prompt: it only stops further processing, and the destructive step was the job it is stopping. There is no `--yes` flag. + +#### Flags + +Only the global flags and `--org-id`. + +## Bulk Operations + +Tagging by query, and untagging in bulk, are **asynchronous**. Rather than acting immediately, they submit a job and return the operation tracking it: + +```bash +$ censys tags assign my-tag --query 'host.services.port: 22' # bulk_create job +$ censys tags unassign my-tag --all # bulk_delete job +$ censys tags unassign my-tag --created-before 2026-01-01T00:00:00Z +``` + +**Entering bulk mode.** `assign` enters it only via `--query`; it is never inferred from a missing asset list. `unassign` enters it via `--all` **or** a time filter. `--all` means *every* assignment, so narrowing it with `--created-before`/`--created-after` contradicts itself and is rejected. In both commands, bulk mode cannot be combined with explicit assets or `--input-file`, and the bulk-only flags (`--max-assets`, `--wait`, `--timeout`) are rejected outside it rather than silently ignored. + +**How many assets a job tags.** A single bulk job tags at most **100,000 assets**, however many the query matches. Three separate limits apply and the smallest wins: this fixed per-job ceiling, your plan's overall tag asset limit, and `--max-assets` if you set one. A job that stops at a limit finishes as `limit_reached` rather than `failed`, and reports how many assets it processed. To cover a query matching more than the ceiling, split it into narrower queries and submit each as its own job. + +**Confirmation.** Bulk operations always prompt before submitting, unless `--yes` is set. In a non-interactive terminal `--yes` is required — the prompt is gated before the job is submitted, so a script without it fails instead of launching a large job silently. + +**Tracking.** Without `--wait` the command prints the operation and the command to track it. With `--wait` it polls until the operation reaches a final status, reporting progress as it goes. + +**Waiting and exit codes.** `--wait` requires bulk mode, and `--timeout` requires `--wait`. `--timeout 0` means *no limit* (matching the global `--timeout-http`); a negative duration is rejected. When a wait ends, the final status maps onto the exit code: + +| Final status | Result | +| ------------------------------ | --------------------------------------------------------------- | +| `succeeded` | exit 0 | +| `limit_reached` | exit 0, with a warning naming how many assets were processed | +| `failed`, `cancelled` | exit 1 | +| timeout expired while running | exit 124 — the job continues server-side | +| interrupted (Ctrl-C) | exit 130 — the job continues server-side | + +A capped run (`limit_reached`) still did its work, so it warns rather than failing. Interrupting or timing out stops only the *polling*; the command prints the `tags operations get` command to resume tracking. + +Use [`tags operations`](#tags-operations-list) to list, inspect, and cancel these jobs afterwards. + +## Search Index Lag + +Tag assignments are not reflected in search immediately. After a successful `assign` or `unassign`, it may take a few minutes for the change to appear in — or disappear from — `tags:` search results. The commands print a note to that effect on success (suppressed by `--quiet`). + +This affects search only. `tags assignments` and the asset count on `tags get` read the assignments directly and reflect changes right away. + +## Output Formats + +All `tags` commands default to **`short`** output. Override with `--output-format` (or `-O`). + +**Default:** `short` +**Supported formats:** `short`, `json`, `yaml`, `tree` + +- **`short`** — human-readable: a styled table for the list-shaped commands (`list`, `assignments`, `operations list`, and the per-asset results of `assign`/`unassign`), a detail view for the single-record commands +- **`json`** — structured JSON +- **`yaml`** — structured YAML +- **`tree`** — hierarchical tree view (interactive; requires a terminal) + +Templates (`-O template`) are not supported for `tags`. + +> **Scripting note:** the `assign` and `unassign` payload **shape depends on the mode**. With explicit assets they emit an **array of per-asset results**; in bulk mode they emit a **single operation object**, identical in shape to `tags operations get`. A script consuming `-O json` from these two commands must branch on which mode it invoked. + +## Exit Codes + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------- | +| 0 | Success, including a partial success where some assets failed | +| 1 | API error, missing credentials, an explicit assign/unassign where no asset succeeded, or a waited-on operation that ended `failed`/`cancelled` | +| 2 | Usage or input error — an invalid flag value, an unknown asset, a rejected flag combination | +| 124 | Timed out | +| 130 | Interrupted | + +Partial failures are reported on stderr and do not change the exit code; check the per-asset results in the output to see which assets failed. + +`assign` and `unassign` always print their per-asset results, even when **every** asset failed — that run exits 1, but the table (or the `-O json` array) still lists each asset and its error. A script can rely on getting parseable output from an explicit assign or unassign whatever the exit code, as long as the tag itself resolved. + +Each failed asset reports a one-line reason and the HTTP status behind it, so you can tell an asset that already carries the tag (`409`) from one you cannot touch (`403`): + +```console +$ censys tags assign my-tag 8.8.8.8 9.9.9.9 + +Assigned tag "my-tag" to 1 of 2 asset(s) + +Asset Type Status Error +9.9.9.9 | host | assigned | - +8.8.8.8 | host | failed | Tag assignment already exists (409) +``` + +In `-O json`/`yaml` those are the `error` and `error_status` fields, so a script can branch on the status code without matching on message text. diff --git a/gen/app/tags/mocks/tagsservice_mock.go b/gen/app/tags/mocks/tagsservice_mock.go new file mode 100644 index 0000000..461b36f --- /dev/null +++ b/gen/app/tags/mocks/tagsservice_mock.go @@ -0,0 +1,253 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/app/tags (interfaces: Service) +// +// Generated by this command: +// +// mockgen -destination=../../../gen/app/tags/mocks/tagsservice_mock.go -package=mocks -mock_names Service=MockTagsService . Service +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + tags "github.com/censys/cencli/internal/app/tags" + cenclierrors "github.com/censys/cencli/internal/pkg/cenclierrors" + gomock "go.uber.org/mock/gomock" +) + +// MockTagsService is a mock of Service interface. +type MockTagsService struct { + ctrl *gomock.Controller + recorder *MockTagsServiceMockRecorder + isgomock struct{} +} + +// MockTagsServiceMockRecorder is the mock recorder for MockTagsService. +type MockTagsServiceMockRecorder struct { + mock *MockTagsService +} + +// NewMockTagsService creates a new mock instance. +func NewMockTagsService(ctrl *gomock.Controller) *MockTagsService { + mock := &MockTagsService{ctrl: ctrl} + mock.recorder = &MockTagsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTagsService) EXPECT() *MockTagsServiceMockRecorder { + return m.recorder +} + +// Assign mocks base method. +func (m *MockTagsService) Assign(ctx context.Context, params tags.AssignParams) (tags.AssignResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Assign", ctx, params) + ret0, _ := ret[0].(tags.AssignResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// Assign indicates an expected call of Assign. +func (mr *MockTagsServiceMockRecorder) Assign(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Assign", reflect.TypeOf((*MockTagsService)(nil).Assign), ctx, params) +} + +// BulkAssign mocks base method. +func (m *MockTagsService) BulkAssign(ctx context.Context, params tags.BulkAssignParams) (tags.BulkAssignResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkAssign", ctx, params) + ret0, _ := ret[0].(tags.BulkAssignResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// BulkAssign indicates an expected call of BulkAssign. +func (mr *MockTagsServiceMockRecorder) BulkAssign(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkAssign", reflect.TypeOf((*MockTagsService)(nil).BulkAssign), ctx, params) +} + +// BulkUnassign mocks base method. +func (m *MockTagsService) BulkUnassign(ctx context.Context, params tags.BulkUnassignParams) (tags.BulkUnassignResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkUnassign", ctx, params) + ret0, _ := ret[0].(tags.BulkUnassignResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// BulkUnassign indicates an expected call of BulkUnassign. +func (mr *MockTagsServiceMockRecorder) BulkUnassign(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkUnassign", reflect.TypeOf((*MockTagsService)(nil).BulkUnassign), ctx, params) +} + +// CancelOperation mocks base method. +func (m *MockTagsService) CancelOperation(ctx context.Context, params tags.CancelOperationParams) (tags.CancelOperationResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CancelOperation", ctx, params) + ret0, _ := ret[0].(tags.CancelOperationResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// CancelOperation indicates an expected call of CancelOperation. +func (mr *MockTagsServiceMockRecorder) CancelOperation(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelOperation", reflect.TypeOf((*MockTagsService)(nil).CancelOperation), ctx, params) +} + +// CreateTag mocks base method. +func (m *MockTagsService) CreateTag(ctx context.Context, params tags.CreateParams) (tags.CreateResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTag", ctx, params) + ret0, _ := ret[0].(tags.CreateResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// CreateTag indicates an expected call of CreateTag. +func (mr *MockTagsServiceMockRecorder) CreateTag(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTag", reflect.TypeOf((*MockTagsService)(nil).CreateTag), ctx, params) +} + +// DeleteTag mocks base method. +func (m *MockTagsService) DeleteTag(ctx context.Context, params tags.DeleteParams) (tags.DeleteResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTag", ctx, params) + ret0, _ := ret[0].(tags.DeleteResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// DeleteTag indicates an expected call of DeleteTag. +func (mr *MockTagsServiceMockRecorder) DeleteTag(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTag", reflect.TypeOf((*MockTagsService)(nil).DeleteTag), ctx, params) +} + +// GetOperation mocks base method. +func (m *MockTagsService) GetOperation(ctx context.Context, params tags.GetOperationParams) (tags.GetOperationResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOperation", ctx, params) + ret0, _ := ret[0].(tags.GetOperationResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetOperation indicates an expected call of GetOperation. +func (mr *MockTagsServiceMockRecorder) GetOperation(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOperation", reflect.TypeOf((*MockTagsService)(nil).GetOperation), ctx, params) +} + +// GetTag mocks base method. +func (m *MockTagsService) GetTag(ctx context.Context, params tags.GetParams) (tags.GetResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTag", ctx, params) + ret0, _ := ret[0].(tags.GetResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetTag indicates an expected call of GetTag. +func (mr *MockTagsServiceMockRecorder) GetTag(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTag", reflect.TypeOf((*MockTagsService)(nil).GetTag), ctx, params) +} + +// ListAssignments mocks base method. +func (m *MockTagsService) ListAssignments(ctx context.Context, params tags.AssignmentsParams) (tags.AssignmentsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAssignments", ctx, params) + ret0, _ := ret[0].(tags.AssignmentsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// ListAssignments indicates an expected call of ListAssignments. +func (mr *MockTagsServiceMockRecorder) ListAssignments(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAssignments", reflect.TypeOf((*MockTagsService)(nil).ListAssignments), ctx, params) +} + +// ListOperations mocks base method. +func (m *MockTagsService) ListOperations(ctx context.Context, params tags.OperationsParams) (tags.OperationsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOperations", ctx, params) + ret0, _ := ret[0].(tags.OperationsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// ListOperations indicates an expected call of ListOperations. +func (mr *MockTagsServiceMockRecorder) ListOperations(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOperations", reflect.TypeOf((*MockTagsService)(nil).ListOperations), ctx, params) +} + +// ListTags mocks base method. +func (m *MockTagsService) ListTags(ctx context.Context, params tags.ListParams) (tags.ListResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTags", ctx, params) + ret0, _ := ret[0].(tags.ListResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// ListTags indicates an expected call of ListTags. +func (mr *MockTagsServiceMockRecorder) ListTags(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTags", reflect.TypeOf((*MockTagsService)(nil).ListTags), ctx, params) +} + +// Unassign mocks base method. +func (m *MockTagsService) Unassign(ctx context.Context, params tags.UnassignParams) (tags.UnassignResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Unassign", ctx, params) + ret0, _ := ret[0].(tags.UnassignResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// Unassign indicates an expected call of Unassign. +func (mr *MockTagsServiceMockRecorder) Unassign(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unassign", reflect.TypeOf((*MockTagsService)(nil).Unassign), ctx, params) +} + +// UpdateTag mocks base method. +func (m *MockTagsService) UpdateTag(ctx context.Context, params tags.UpdateParams) (tags.UpdateResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTag", ctx, params) + ret0, _ := ret[0].(tags.UpdateResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// UpdateTag indicates an expected call of UpdateTag. +func (mr *MockTagsServiceMockRecorder) UpdateTag(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTag", reflect.TypeOf((*MockTagsService)(nil).UpdateTag), ctx, params) +} + +// WaitForOperation mocks base method. +func (m *MockTagsService) WaitForOperation(ctx context.Context, params tags.WaitParams) (tags.GetOperationResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WaitForOperation", ctx, params) + ret0, _ := ret[0].(tags.GetOperationResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// WaitForOperation indicates an expected call of WaitForOperation. +func (mr *MockTagsServiceMockRecorder) WaitForOperation(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WaitForOperation", reflect.TypeOf((*MockTagsService)(nil).WaitForOperation), ctx, params) +} diff --git a/gen/client/mocks/censys_client_mock.go b/gen/client/mocks/censys_client_mock.go index 7582994..38f0d15 100644 --- a/gen/client/mocks/censys_client_mock.go +++ b/gen/client/mocks/censys_client_mock.go @@ -75,6 +75,81 @@ func (mr *MockClientMockRecorder) AggregateCollection(ctx, collectionID, orgID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregateCollection", reflect.TypeOf((*MockClient)(nil).AggregateCollection), ctx, collectionID, orgID, query, field, numBuckets, countByLevel, filterByQuery) } +// BulkCreateTagAssignments mocks base method. +func (m *MockClient) BulkCreateTagAssignments(ctx context.Context, req censys.BulkCreateTagAssignmentsRequest) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkCreateTagAssignments", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// BulkCreateTagAssignments indicates an expected call of BulkCreateTagAssignments. +func (mr *MockClientMockRecorder) BulkCreateTagAssignments(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkCreateTagAssignments", reflect.TypeOf((*MockClient)(nil).BulkCreateTagAssignments), ctx, req) +} + +// BulkDeleteTagAssignments mocks base method. +func (m *MockClient) BulkDeleteTagAssignments(ctx context.Context, req censys.BulkDeleteTagAssignmentsRequest) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkDeleteTagAssignments", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// BulkDeleteTagAssignments indicates an expected call of BulkDeleteTagAssignments. +func (mr *MockClientMockRecorder) BulkDeleteTagAssignments(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkDeleteTagAssignments", reflect.TypeOf((*MockClient)(nil).BulkDeleteTagAssignments), ctx, req) +} + +// CancelTagOperation mocks base method. +func (m *MockClient) CancelTagOperation(ctx context.Context, orgID mo.Option[string], tagID, operationID string) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CancelTagOperation", ctx, orgID, tagID, operationID) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// CancelTagOperation indicates an expected call of CancelTagOperation. +func (mr *MockClientMockRecorder) CancelTagOperation(ctx, orgID, tagID, operationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelTagOperation", reflect.TypeOf((*MockClient)(nil).CancelTagOperation), ctx, orgID, tagID, operationID) +} + +// CreateTag mocks base method. +func (m *MockClient) CreateTag(ctx context.Context, req censys.CreateTagRequest) (censys.Result[components.Tag], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTag", ctx, req) + ret0, _ := ret[0].(censys.Result[components.Tag]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// CreateTag indicates an expected call of CreateTag. +func (mr *MockClientMockRecorder) CreateTag(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTag", reflect.TypeOf((*MockClient)(nil).CreateTag), ctx, req) +} + +// CreateTagAssignment mocks base method. +func (m *MockClient) CreateTagAssignment(ctx context.Context, req censys.CreateTagAssignmentRequest) (censys.Result[components.TagAssignment], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTagAssignment", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagAssignment]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// CreateTagAssignment indicates an expected call of CreateTagAssignment. +func (mr *MockClientMockRecorder) CreateTagAssignment(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTagAssignment", reflect.TypeOf((*MockClient)(nil).CreateTagAssignment), ctx, req) +} + // CredentialInfo mocks base method. func (m *MockClient) CredentialInfo() credential.Info { m.ctrl.T.Helper() @@ -89,6 +164,36 @@ func (mr *MockClientMockRecorder) CredentialInfo() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CredentialInfo", reflect.TypeOf((*MockClient)(nil).CredentialInfo)) } +// DeleteTag mocks base method. +func (m *MockClient) DeleteTag(ctx context.Context, orgID mo.Option[string], tagID string) (censys.Metadata, censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTag", ctx, orgID, tagID) + ret0, _ := ret[0].(censys.Metadata) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// DeleteTag indicates an expected call of DeleteTag. +func (mr *MockClientMockRecorder) DeleteTag(ctx, orgID, tagID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTag", reflect.TypeOf((*MockClient)(nil).DeleteTag), ctx, orgID, tagID) +} + +// DeleteTagAssignment mocks base method. +func (m *MockClient) DeleteTagAssignment(ctx context.Context, orgID mo.Option[string], tagID, assignmentID string) (censys.Metadata, censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTagAssignment", ctx, orgID, tagID, assignmentID) + ret0, _ := ret[0].(censys.Metadata) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// DeleteTagAssignment indicates an expected call of DeleteTagAssignment. +func (mr *MockClientMockRecorder) DeleteTagAssignment(ctx, orgID, tagID, assignmentID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTagAssignment", reflect.TypeOf((*MockClient)(nil).DeleteTagAssignment), ctx, orgID, tagID, assignmentID) +} + // EnrichHost mocks base method. func (m *MockClient) EnrichHost(ctx context.Context, orgID mo.Option[string], hostIP string) (censys.Result[components.HostEnrichment], censys.ClientError) { m.ctrl.T.Helper() @@ -179,6 +284,36 @@ func (mr *MockClientMockRecorder) GetOrganizationDetails(ctx, orgID, includeMemb return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockClient)(nil).GetOrganizationDetails), ctx, orgID, includeMemberCounts) } +// GetTag mocks base method. +func (m *MockClient) GetTag(ctx context.Context, orgID mo.Option[string], tagID string) (censys.Result[components.Tag], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTag", ctx, orgID, tagID) + ret0, _ := ret[0].(censys.Result[components.Tag]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetTag indicates an expected call of GetTag. +func (mr *MockClientMockRecorder) GetTag(ctx, orgID, tagID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTag", reflect.TypeOf((*MockClient)(nil).GetTag), ctx, orgID, tagID) +} + +// GetTagOperation mocks base method. +func (m *MockClient) GetTagOperation(ctx context.Context, orgID mo.Option[string], tagID, operationID string) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagOperation", ctx, orgID, tagID, operationID) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetTagOperation indicates an expected call of GetTagOperation. +func (mr *MockClientMockRecorder) GetTagOperation(ctx, orgID, tagID, operationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagOperation", reflect.TypeOf((*MockClient)(nil).GetTagOperation), ctx, orgID, tagID, operationID) +} + // GetUserCreditDetails mocks base method. func (m *MockClient) GetUserCreditDetails(ctx context.Context) (censys.Result[components.UserCredits], censys.ClientError) { m.ctrl.T.Helper() @@ -268,6 +403,51 @@ func (mr *MockClientMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockClient)(nil).ListOrganizationMembers), ctx, orgID, pageSize, pageToken) } +// ListTagAssignments mocks base method. +func (m *MockClient) ListTagAssignments(ctx context.Context, req censys.ListTagAssignmentsRequest) (censys.Result[components.TagAssignmentsList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTagAssignments", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagAssignmentsList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListTagAssignments indicates an expected call of ListTagAssignments. +func (mr *MockClientMockRecorder) ListTagAssignments(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTagAssignments", reflect.TypeOf((*MockClient)(nil).ListTagAssignments), ctx, req) +} + +// ListTagOperations mocks base method. +func (m *MockClient) ListTagOperations(ctx context.Context, req censys.ListTagOperationsRequest) (censys.Result[components.TagOperationsList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTagOperations", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagOperationsList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListTagOperations indicates an expected call of ListTagOperations. +func (mr *MockClientMockRecorder) ListTagOperations(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTagOperations", reflect.TypeOf((*MockClient)(nil).ListTagOperations), ctx, req) +} + +// ListTags mocks base method. +func (m *MockClient) ListTags(ctx context.Context, req censys.ListTagsRequest) (censys.Result[components.TagsList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTags", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagsList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListTags indicates an expected call of ListTags. +func (mr *MockClientMockRecorder) ListTags(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTags", reflect.TypeOf((*MockClient)(nil).ListTags), ctx, req) +} + // Search mocks base method. func (m *MockClient) Search(ctx context.Context, orgID mo.Option[string], query string, fields []string, pageSize mo.Option[int64], pageToken mo.Option[string]) (censys.Result[components.SearchQueryResponse], censys.ClientError) { m.ctrl.T.Helper() @@ -297,3 +477,18 @@ func (mr *MockClientMockRecorder) SearchCollection(ctx, collectionID, orgID, que mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchCollection", reflect.TypeOf((*MockClient)(nil).SearchCollection), ctx, collectionID, orgID, query, fields, pageSize, pageToken) } + +// UpdateTag mocks base method. +func (m *MockClient) UpdateTag(ctx context.Context, req censys.UpdateTagRequest) (censys.Result[components.Tag], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTag", ctx, req) + ret0, _ := ret[0].(censys.Result[components.Tag]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// UpdateTag indicates an expected call of UpdateTag. +func (mr *MockClientMockRecorder) UpdateTag(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTag", reflect.TypeOf((*MockClient)(nil).UpdateTag), ctx, req) +} diff --git a/gen/client/mocks/tags_mock.go b/gen/client/mocks/tags_mock.go new file mode 100644 index 0000000..6c2de60 --- /dev/null +++ b/gen/client/mocks/tags_mock.go @@ -0,0 +1,239 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/pkg/clients/censys (interfaces: TagsClient) +// +// Generated by this command: +// +// mockgen -destination=../../../../gen/client/mocks/tags_mock.go -package=mocks github.com/censys/cencli/internal/pkg/clients/censys TagsClient +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + censys "github.com/censys/cencli/internal/pkg/clients/censys" + components "github.com/censys/censys-sdk-go/models/components" + mo "github.com/samber/mo" + gomock "go.uber.org/mock/gomock" +) + +// MockTagsClient is a mock of TagsClient interface. +type MockTagsClient struct { + ctrl *gomock.Controller + recorder *MockTagsClientMockRecorder + isgomock struct{} +} + +// MockTagsClientMockRecorder is the mock recorder for MockTagsClient. +type MockTagsClientMockRecorder struct { + mock *MockTagsClient +} + +// NewMockTagsClient creates a new mock instance. +func NewMockTagsClient(ctrl *gomock.Controller) *MockTagsClient { + mock := &MockTagsClient{ctrl: ctrl} + mock.recorder = &MockTagsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTagsClient) EXPECT() *MockTagsClientMockRecorder { + return m.recorder +} + +// BulkCreateTagAssignments mocks base method. +func (m *MockTagsClient) BulkCreateTagAssignments(ctx context.Context, req censys.BulkCreateTagAssignmentsRequest) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkCreateTagAssignments", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// BulkCreateTagAssignments indicates an expected call of BulkCreateTagAssignments. +func (mr *MockTagsClientMockRecorder) BulkCreateTagAssignments(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkCreateTagAssignments", reflect.TypeOf((*MockTagsClient)(nil).BulkCreateTagAssignments), ctx, req) +} + +// BulkDeleteTagAssignments mocks base method. +func (m *MockTagsClient) BulkDeleteTagAssignments(ctx context.Context, req censys.BulkDeleteTagAssignmentsRequest) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkDeleteTagAssignments", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// BulkDeleteTagAssignments indicates an expected call of BulkDeleteTagAssignments. +func (mr *MockTagsClientMockRecorder) BulkDeleteTagAssignments(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkDeleteTagAssignments", reflect.TypeOf((*MockTagsClient)(nil).BulkDeleteTagAssignments), ctx, req) +} + +// CancelTagOperation mocks base method. +func (m *MockTagsClient) CancelTagOperation(ctx context.Context, orgID mo.Option[string], tagID, operationID string) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CancelTagOperation", ctx, orgID, tagID, operationID) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// CancelTagOperation indicates an expected call of CancelTagOperation. +func (mr *MockTagsClientMockRecorder) CancelTagOperation(ctx, orgID, tagID, operationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelTagOperation", reflect.TypeOf((*MockTagsClient)(nil).CancelTagOperation), ctx, orgID, tagID, operationID) +} + +// CreateTag mocks base method. +func (m *MockTagsClient) CreateTag(ctx context.Context, req censys.CreateTagRequest) (censys.Result[components.Tag], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTag", ctx, req) + ret0, _ := ret[0].(censys.Result[components.Tag]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// CreateTag indicates an expected call of CreateTag. +func (mr *MockTagsClientMockRecorder) CreateTag(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTag", reflect.TypeOf((*MockTagsClient)(nil).CreateTag), ctx, req) +} + +// CreateTagAssignment mocks base method. +func (m *MockTagsClient) CreateTagAssignment(ctx context.Context, req censys.CreateTagAssignmentRequest) (censys.Result[components.TagAssignment], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTagAssignment", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagAssignment]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// CreateTagAssignment indicates an expected call of CreateTagAssignment. +func (mr *MockTagsClientMockRecorder) CreateTagAssignment(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTagAssignment", reflect.TypeOf((*MockTagsClient)(nil).CreateTagAssignment), ctx, req) +} + +// DeleteTag mocks base method. +func (m *MockTagsClient) DeleteTag(ctx context.Context, orgID mo.Option[string], tagID string) (censys.Metadata, censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTag", ctx, orgID, tagID) + ret0, _ := ret[0].(censys.Metadata) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// DeleteTag indicates an expected call of DeleteTag. +func (mr *MockTagsClientMockRecorder) DeleteTag(ctx, orgID, tagID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTag", reflect.TypeOf((*MockTagsClient)(nil).DeleteTag), ctx, orgID, tagID) +} + +// DeleteTagAssignment mocks base method. +func (m *MockTagsClient) DeleteTagAssignment(ctx context.Context, orgID mo.Option[string], tagID, assignmentID string) (censys.Metadata, censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTagAssignment", ctx, orgID, tagID, assignmentID) + ret0, _ := ret[0].(censys.Metadata) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// DeleteTagAssignment indicates an expected call of DeleteTagAssignment. +func (mr *MockTagsClientMockRecorder) DeleteTagAssignment(ctx, orgID, tagID, assignmentID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTagAssignment", reflect.TypeOf((*MockTagsClient)(nil).DeleteTagAssignment), ctx, orgID, tagID, assignmentID) +} + +// GetTag mocks base method. +func (m *MockTagsClient) GetTag(ctx context.Context, orgID mo.Option[string], tagID string) (censys.Result[components.Tag], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTag", ctx, orgID, tagID) + ret0, _ := ret[0].(censys.Result[components.Tag]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetTag indicates an expected call of GetTag. +func (mr *MockTagsClientMockRecorder) GetTag(ctx, orgID, tagID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTag", reflect.TypeOf((*MockTagsClient)(nil).GetTag), ctx, orgID, tagID) +} + +// GetTagOperation mocks base method. +func (m *MockTagsClient) GetTagOperation(ctx context.Context, orgID mo.Option[string], tagID, operationID string) (censys.Result[components.TagOperation], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagOperation", ctx, orgID, tagID, operationID) + ret0, _ := ret[0].(censys.Result[components.TagOperation]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetTagOperation indicates an expected call of GetTagOperation. +func (mr *MockTagsClientMockRecorder) GetTagOperation(ctx, orgID, tagID, operationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagOperation", reflect.TypeOf((*MockTagsClient)(nil).GetTagOperation), ctx, orgID, tagID, operationID) +} + +// ListTagAssignments mocks base method. +func (m *MockTagsClient) ListTagAssignments(ctx context.Context, req censys.ListTagAssignmentsRequest) (censys.Result[components.TagAssignmentsList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTagAssignments", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagAssignmentsList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListTagAssignments indicates an expected call of ListTagAssignments. +func (mr *MockTagsClientMockRecorder) ListTagAssignments(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTagAssignments", reflect.TypeOf((*MockTagsClient)(nil).ListTagAssignments), ctx, req) +} + +// ListTagOperations mocks base method. +func (m *MockTagsClient) ListTagOperations(ctx context.Context, req censys.ListTagOperationsRequest) (censys.Result[components.TagOperationsList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTagOperations", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagOperationsList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListTagOperations indicates an expected call of ListTagOperations. +func (mr *MockTagsClientMockRecorder) ListTagOperations(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTagOperations", reflect.TypeOf((*MockTagsClient)(nil).ListTagOperations), ctx, req) +} + +// ListTags mocks base method. +func (m *MockTagsClient) ListTags(ctx context.Context, req censys.ListTagsRequest) (censys.Result[components.TagsList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTags", ctx, req) + ret0, _ := ret[0].(censys.Result[components.TagsList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListTags indicates an expected call of ListTags. +func (mr *MockTagsClientMockRecorder) ListTags(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTags", reflect.TypeOf((*MockTagsClient)(nil).ListTags), ctx, req) +} + +// UpdateTag mocks base method. +func (m *MockTagsClient) UpdateTag(ctx context.Context, req censys.UpdateTagRequest) (censys.Result[components.Tag], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTag", ctx, req) + ret0, _ := ret[0].(censys.Result[components.Tag]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// UpdateTag indicates an expected call of UpdateTag. +func (mr *MockTagsClientMockRecorder) UpdateTag(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTag", reflect.TypeOf((*MockTagsClient)(nil).UpdateTag), ctx, req) +} diff --git a/go.mod b/go.mod index f890338..48b49da 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.10 require ( github.com/aymerick/raymond v2.0.2+incompatible - github.com/censys/censys-sdk-go v0.25.24 + github.com/censys/censys-sdk-go v0.25.26 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.7.0 diff --git a/go.sum b/go.sum index c2cad4a..36522c0 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfK github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/censys/censys-sdk-go v0.25.24 h1:CSu+uIsPTHS6M/3axYUYLVTp/RrmUHY/+nkDoZkqn4I= -github.com/censys/censys-sdk-go v0.25.24/go.mod h1:YfqANSOdycVhpGC6He7fYEom1/S33oMzbtG7fYYXnQw= +github.com/censys/censys-sdk-go v0.25.26 h1:mv7oCRiOQ5FckGpol+tpFidLJSZ86ynKDwIj3Dyb51Y= +github.com/censys/censys-sdk-go v0.25.26/go.mod h1:YfqANSOdycVhpGC6He7fYEom1/S33oMzbtG7fYYXnQw= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= diff --git a/internal/app/tags/bulk.go b/internal/app/tags/bulk.go new file mode 100644 index 0000000..c8663e3 --- /dev/null +++ b/internal/app/tags/bulk.go @@ -0,0 +1,77 @@ +package tags + +import ( + "context" + "strings" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + utilconvert "github.com/censys/cencli/internal/pkg/convertutil" +) + +// BulkAssign assigns a tag (by name or UUID) to every asset matching a CenQL +// query. The endpoint answers 202 with the operation tracking the job, so this +// returns as soon as the job is accepted; the caller decides whether to wait. +func (s *tagsService) BulkAssign( + ctx context.Context, + params BulkAssignParams, +) (BulkAssignResult, cenclierrors.CencliError) { + query := strings.TrimSpace(params.Query) + if query == "" { + return BulkAssignResult{}, NewEmptyQueryError() + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + // The endpoint keys off the tag UUID, so a name costs one lookup first. + result, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (client.Result[components.TagOperation], cenclierrors.CencliError) { + return s.client.BulkCreateTagAssignments(ctx, client.BulkCreateTagAssignmentsRequest{ + OrgID: orgIDStr, + TagID: tagID, + Query: query, + MaxAssets: params.MaxAssets, + }) + }) + if err != nil { + return BulkAssignResult{}, err + } + + meta, operation := mapOperationResult(result) + return BulkAssignResult{Meta: meta, Operation: operation}, nil +} + +// BulkUnassign removes a tag (by name or UUID) from the assignments matching the +// given time filters, or from every assignment when no filter is given. Like +// BulkAssign it returns as soon as the job is accepted. +func (s *tagsService) BulkUnassign( + ctx context.Context, + params BulkUnassignParams, +) (BulkUnassignResult, cenclierrors.CencliError) { + // An impossible window would remove nothing while still spending an + // operation, which reads like a successful wipe. + if err := ValidateTimeWindow(params.CreatedBefore, params.CreatedAfter); err != nil { + return BulkUnassignResult{}, err + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + // The endpoint keys off the tag UUID, so a name costs one lookup first. + result, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (client.Result[components.TagOperation], cenclierrors.CencliError) { + return s.client.BulkDeleteTagAssignments(ctx, client.BulkDeleteTagAssignmentsRequest{ + OrgID: orgIDStr, + TagID: tagID, + CreatedBefore: params.CreatedBefore, + CreatedAfter: params.CreatedAfter, + }) + }) + if err != nil { + return BulkUnassignResult{}, err + } + + meta, operation := mapOperationResult(result) + return BulkUnassignResult{Meta: meta, Operation: operation}, nil +} diff --git a/internal/app/tags/bulk_test.go b/internal/app/tags/bulk_test.go new file mode 100644 index 0000000..dd5b790 --- /dev/null +++ b/internal/app/tags/bulk_test.go @@ -0,0 +1,440 @@ +package tags + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/gen/client/mocks" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" +) + +// bulkOrgUUID stands in for an --org-id override on a bulk submit. +var bulkOrgUUID = uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479") + +// Fixed timestamps for the bulk-delete time filters, ordered so before > after +// (the only combination the service accepts). +var ( + bulkAfterTime = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + bulkBeforeTime = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) +) + +// deleteOperationResult is operationResult's bulk_delete twin: TotalCount stays +// zero because the API only sets it once a bulk delete completes. +func deleteOperationResult(status components.TagOperationStatus) client.Result[components.TagOperation] { + return client.Result[components.TagOperation]{ + Metadata: okMeta(), + Data: &components.TagOperation{ + ID: testOpUUID, + TagID: testTagUUID, + TagName: "my-tag", + Type: components.TagOperationTypeBulkDelete, + Status: status, + }, + } +} + +// bulkNameLookup expects the one ListTags call that resolves a tag name. +func bulkNameLookup(m *mocks.MockClient, name string) { + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some(name), PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{{ID: testTagUUID, Name: name}}, TotalSize: 1}, + }, nil) +} + +func TestTagsService_BulkAssign(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params BulkAssignParams + assert func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) + }{ + { + name: "UUID tag submits without a lookup and returns the operation", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + // The durable guard: a UUID must cost zero lookup requests. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), client.BulkCreateTagAssignmentsRequest{ + TagID: testTagUUID, + Query: "host.services.port: 22", + }).Return(operationResult(components.TagOperationStatusPending, 0), nil) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID(testTagUUID), + Query: "host.services.port: 22", + }, + assert: func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, testOpUUID, res.Operation.ID) + require.Equal(t, "pending", res.Operation.Status) + require.Equal(t, "bulk_create", res.Operation.Type) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name is resolved to a UUID before submitting", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + bulkNameLookup(m, "my-tag") + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), client.BulkCreateTagAssignmentsRequest{ + TagID: testTagUUID, + Query: "host.ip: 1.1.1.1", + }).Return(operationResult(components.TagOperationStatusRunning, 0), nil) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID("my-tag"), + Query: "host.ip: 1.1.1.1", + }, + assert: func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, testOpUUID, res.Operation.ID) + }, + }, + { + name: "max assets is sent when set", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), client.BulkCreateTagAssignmentsRequest{ + TagID: testTagUUID, + Query: "host.ip: 1.1.1.1", + MaxAssets: mo.Some(int64(500)), + }).Return(operationResult(components.TagOperationStatusPending, 0), nil) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID(testTagUUID), + Query: "host.ip: 1.1.1.1", + MaxAssets: mo.Some(int64(500)), + }, + assert: func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "zero max assets is passed through as no explicit cap", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), client.BulkCreateTagAssignmentsRequest{ + TagID: testTagUUID, + Query: "host.ip: 1.1.1.1", + MaxAssets: mo.Some(int64(0)), + }).Return(operationResult(components.TagOperationStatusPending, 0), nil) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID(testTagUUID), + Query: "host.ip: 1.1.1.1", + MaxAssets: mo.Some(int64(0)), + }, + assert: func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "query is trimmed before it is sent", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), client.BulkCreateTagAssignmentsRequest{ + TagID: testTagUUID, + Query: "host.ip: 1.1.1.1", + }).Return(operationResult(components.TagOperationStatusPending, 0), nil) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID(testTagUUID), + Query: " host.ip: 1.1.1.1 ", + }, + assert: func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "org id is threaded through to the request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), client.BulkCreateTagAssignmentsRequest{ + OrgID: mo.Some(bulkOrgUUID.String()), + TagID: testTagUUID, + Query: "host.ip: 1.1.1.1", + }).Return(operationResult(components.TagOperationStatusPending, 0), nil) + return m + }, + params: BulkAssignParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(bulkOrgUUID)), + TagID: identifiers.NewTagID(testTagUUID), + Query: "host.ip: 1.1.1.1", + }, + assert: func(t *testing.T, res BulkAssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "empty query is rejected without touching the API", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID(testTagUUID), + Query: " ", + }, + assert: func(t *testing.T, _ BulkAssignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "--query must not be empty") + }, + }, + { + name: "unknown tag name fails before submitting", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{}, TotalSize: 0}, + }, nil) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID("ghost"), + Query: "host.ip: 1.1.1.1", + }, + assert: func(t *testing.T, _ BulkAssignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "ghost") + }, + }, + { + name: "client error is returned as-is", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkCreateTagAssignments(gomock.Any(), gomock.Any()).Return( + client.Result[components.TagOperation]{}, + clientStructuredError("Permission denied", 403), + ) + expectNoTagOfThatName(m) + return m + }, + params: BulkAssignParams{ + TagID: identifiers.NewTagID(testTagUUID), + Query: "host.ip: 1.1.1.1", + }, + assert: func(t *testing.T, _ BulkAssignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "Permission denied") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := &tagsService{client: tc.client(ctrl)} + res, err := svc.BulkAssign(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +func TestTagsService_BulkUnassign(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params BulkUnassignParams + assert func(t *testing.T, res BulkUnassignResult, err cenclierrors.CencliError) + }{ + { + // No filters means every assignment: the request must carry neither + // timestamp, since a stray one would silently narrow the wipe. + name: "no filters submits an unfiltered removal without a lookup", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), client.BulkDeleteTagAssignmentsRequest{ + TagID: testTagUUID, + }).Return(deleteOperationResult(components.TagOperationStatusPending), nil) + return m + }, + params: BulkUnassignParams{TagID: identifiers.NewTagID(testTagUUID)}, + assert: func(t *testing.T, res BulkUnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, testOpUUID, res.Operation.ID) + require.Equal(t, "pending", res.Operation.Status) + require.Equal(t, "bulk_delete", res.Operation.Type) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name is resolved to a UUID before submitting", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + bulkNameLookup(m, "my-tag") + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), client.BulkDeleteTagAssignmentsRequest{ + TagID: testTagUUID, + }).Return(deleteOperationResult(components.TagOperationStatusRunning), nil) + return m + }, + params: BulkUnassignParams{TagID: identifiers.NewTagID("my-tag")}, + assert: func(t *testing.T, res BulkUnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, testOpUUID, res.Operation.ID) + }, + }, + { + name: "created-before alone is sent alone", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), client.BulkDeleteTagAssignmentsRequest{ + TagID: testTagUUID, + CreatedBefore: mo.Some(bulkBeforeTime), + }).Return(deleteOperationResult(components.TagOperationStatusPending), nil) + return m + }, + params: BulkUnassignParams{ + TagID: identifiers.NewTagID(testTagUUID), + CreatedBefore: mo.Some(bulkBeforeTime), + }, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "created-after alone is sent alone", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), client.BulkDeleteTagAssignmentsRequest{ + TagID: testTagUUID, + CreatedAfter: mo.Some(bulkAfterTime), + }).Return(deleteOperationResult(components.TagOperationStatusPending), nil) + return m + }, + params: BulkUnassignParams{ + TagID: identifiers.NewTagID(testTagUUID), + CreatedAfter: mo.Some(bulkAfterTime), + }, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "both filters bound the window", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), client.BulkDeleteTagAssignmentsRequest{ + TagID: testTagUUID, + CreatedBefore: mo.Some(bulkBeforeTime), + CreatedAfter: mo.Some(bulkAfterTime), + }).Return(deleteOperationResult(components.TagOperationStatusPending), nil) + return m + }, + params: BulkUnassignParams{ + TagID: identifiers.NewTagID(testTagUUID), + CreatedBefore: mo.Some(bulkBeforeTime), + CreatedAfter: mo.Some(bulkAfterTime), + }, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "org id is threaded through to the request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), client.BulkDeleteTagAssignmentsRequest{ + OrgID: mo.Some(bulkOrgUUID.String()), + TagID: testTagUUID, + }).Return(deleteOperationResult(components.TagOperationStatusPending), nil) + return m + }, + params: BulkUnassignParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(bulkOrgUUID)), + TagID: identifiers.NewTagID(testTagUUID), + }, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + // An inverted window matches nothing, which would look like a wipe that + // found no assignments rather than a mistake. + name: "impossible window is rejected without touching the API", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: BulkUnassignParams{ + TagID: identifiers.NewTagID(testTagUUID), + CreatedBefore: mo.Some(bulkAfterTime), + CreatedAfter: mo.Some(bulkBeforeTime), + }, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "created-before must be after created-after") + }, + }, + { + name: "unknown tag name fails before submitting", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{}, TotalSize: 0}, + }, nil) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: BulkUnassignParams{TagID: identifiers.NewTagID("ghost")}, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "ghost") + }, + }, + { + name: "client error is returned as-is", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().BulkDeleteTagAssignments(gomock.Any(), gomock.Any()).Return( + client.Result[components.TagOperation]{}, + clientStructuredError("Permission denied", 403), + ) + expectNoTagOfThatName(m) + return m + }, + params: BulkUnassignParams{TagID: identifiers.NewTagID(testTagUUID)}, + assert: func(t *testing.T, _ BulkUnassignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "Permission denied") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := &tagsService{client: tc.client(ctrl)} + res, err := svc.BulkUnassign(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} diff --git a/internal/app/tags/dto.go b/internal/app/tags/dto.go new file mode 100644 index 0000000..d847227 --- /dev/null +++ b/internal/app/tags/dto.go @@ -0,0 +1,364 @@ +package tags + +import ( + "errors" + "time" + + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +// ListParams bundles inputs for listing tags. Filters left empty are omitted +// from the request. Using a struct keeps the service API extensible as later +// tickets add commands. +type ListParams struct { + OrgID mo.Option[identifiers.OrganizationID] + Privacy mo.Option[string] + Name mo.Option[string] + CreatedBy mo.Option[string] + OrderBy mo.Option[string] + PageSize mo.Option[uint64] + MaxPages mo.Option[uint64] +} + +// GetParams bundles inputs for retrieving a single tag by name or UUID. +type GetParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID +} + +// CreateParams bundles inputs for creating a tag. +type CreateParams struct { + OrgID mo.Option[identifiers.OrganizationID] + Name string + Description mo.Option[string] + Privacy string +} + +// UpdateParams bundles inputs for updating a tag by name or UUID. Clearing the +// description is expressed as Description = mo.Some(""). +type UpdateParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + Name mo.Option[string] + Description mo.Option[string] + Privacy mo.Option[string] +} + +// DeleteParams bundles inputs for deleting a tag by name or UUID. +type DeleteParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID +} + +// AssignParams bundles inputs for assigning a tag (by name or UUID) to explicit +// assets. AssetIDs are validated and deduplicated by the command layer. +type AssignParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + AssetIDs []string +} + +// UnassignParams bundles inputs for unassigning a tag (by name or UUID) from +// explicit assets. AssetIDs are validated and deduplicated by the command layer. +type UnassignParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + AssetIDs []string +} + +// BulkAssignParams bundles inputs for assigning a tag (by name or UUID) to every +// asset matching a CenQL query. An absent MaxAssets leaves the cap to the plan's +// tag asset limit. +type BulkAssignParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + Query string + MaxAssets mo.Option[int64] +} + +// BulkAssignResult is the outcome of submitting a bulk assignment. The endpoint +// answers 202 with the operation tracking the job, not the assignments, so the +// caller polls the operation to learn how it ended. +type BulkAssignResult struct { + Meta *responsemeta.ResponseMeta + Operation TagOperation +} + +// BulkUnassignParams bundles inputs for removing a tag (by name or UUID) from +// assignments selected by filter rather than by asset. With neither timestamp +// present every assignment of the tag is removed. +type BulkUnassignParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + CreatedBefore mo.Option[time.Time] + CreatedAfter mo.Option[time.Time] +} + +// BulkUnassignResult is the outcome of submitting a bulk unassignment. Like its +// create counterpart the endpoint answers 202 with the operation tracking the +// job, so the caller polls the operation to learn how it ended. +type BulkUnassignResult struct { + Meta *responsemeta.ResponseMeta + Operation TagOperation +} + +// AssignmentsParams bundles inputs for listing a tag's assignments. Filters left +// empty are omitted from the request. +type AssignmentsParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + AssetID mo.Option[string] + AssetType mo.Option[string] + CreatedBy mo.Option[string] + CreatedBefore mo.Option[time.Time] + CreatedAfter mo.Option[time.Time] + OrderBy mo.Option[string] + PageSize mo.Option[uint64] + MaxPages mo.Option[uint64] +} + +// OperationsParams bundles inputs for listing bulk tag operations. An absent +// TagID lists operations across every tag in the organization. +type OperationsParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID mo.Option[identifiers.TagID] + Type mo.Option[string] + Status mo.Option[string] + OrderBy mo.Option[string] + PageSize mo.Option[uint64] + MaxPages mo.Option[uint64] +} + +// GetOperationParams bundles inputs for retrieving one bulk tag operation. The +// endpoint is keyed by UUID, so a tag name is resolved first. +type GetOperationParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + OperationID string +} + +// CancelOperationParams bundles inputs for cancelling one bulk tag operation. +// Both path parameters are UUID-only, so a tag name is resolved first. +type CancelOperationParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + OperationID string +} + +// CancelOperationResult is the outcome of requesting a cancellation. The +// operation comes back as it stood when the request was accepted, which may +// still be a non-terminal status while the job winds down. +type CancelOperationResult struct { + Meta *responsemeta.ResponseMeta + Operation TagOperation +} + +// WaitParams bundles inputs for polling an operation until it finishes. An +// absent Timeout polls until a terminal status or context cancellation. +type WaitParams struct { + OrgID mo.Option[identifiers.OrganizationID] + TagID identifiers.TagID + OperationID string + Timeout mo.Option[time.Duration] +} + +// TagOperation is the domain representation of an asynchronous bulk tag job. +// The optional fields stay pointers so absent values are omitted from json and +// yaml output rather than rendered as empty strings. +type TagOperation struct { + ID string `json:"id" yaml:"id"` + TagID string `json:"tag_id" yaml:"tag_id"` + TagName string `json:"tag_name" yaml:"tag_name"` + Type string `json:"type" yaml:"type"` + Status string `json:"status" yaml:"status"` + // Query is only set for bulk_create operations. + Query *string `json:"query,omitempty" yaml:"query,omitempty"` + // TotalCount is approximate at start for bulk_create, and set at completion + // for bulk_delete. + TotalCount int64 `json:"total_count" yaml:"total_count"` + ProcessedCount int64 `json:"processed_count" yaml:"processed_count"` + SuccessfulCount int64 `json:"successful_count" yaml:"successful_count"` + // StatusMessage is set once the operation finishes; it mirrors ErrorMessage + // on failure and explains the cap on limit_reached. + StatusMessage *string `json:"status_message,omitempty" yaml:"status_message,omitempty"` + ErrorMessage *string `json:"error_message,omitempty" yaml:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + EndedAt *time.Time `json:"ended_at,omitempty" yaml:"ended_at,omitempty"` +} + +// OperationsResult is the outcome of listing bulk tag operations. +type OperationsResult struct { + Meta *responsemeta.ResponseMeta + Operations []TagOperation + // TotalSize is the API's count of operations matching the filters. + TotalSize int64 + // PartialError summarizes an error hit after the first successful page. + PartialError cenclierrors.CencliError +} + +// GetOperationResult is the outcome of retrieving or waiting on one operation. +// A terminal status is not an error here: the operation is returned for every +// outcome and the command layer owns the exit-code policy, which keeps the data +// payload intact for a failed job. +type GetOperationResult struct { + Meta *responsemeta.ResponseMeta + Operation TagOperation +} + +// Tag is the domain representation of a Censys tag, decoupled from the SDK type. +type Tag struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Privacy string `json:"privacy" yaml:"privacy"` + CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + // AssetCount is only populated by `get`, which counts the assignments in a + // second request; it stays nil elsewhere, and on a get whose count failed. + AssetCount *int64 `json:"asset_count,omitempty" yaml:"asset_count,omitempty"` +} + +// GetResult is the outcome of retrieving a single tag. +type GetResult struct { + Meta *responsemeta.ResponseMeta + Tag Tag + // PartialError is set when the tag was fetched but the opted-in asset count + // could not be; the tag itself is still valid. + PartialError cenclierrors.CencliError +} + +// CreateResult is the outcome of creating a tag. +type CreateResult struct { + Meta *responsemeta.ResponseMeta + Tag Tag +} + +// UpdateResult is the outcome of updating a tag. +type UpdateResult struct { + Meta *responsemeta.ResponseMeta + Tag Tag +} + +// DeleteResult is the outcome of deleting a tag. The endpoint returns no tag +// body, so only the identifier the caller supplied is echoed back for rendering. +type DeleteResult struct { + Meta *responsemeta.ResponseMeta + TagID string +} + +// Assignment is the domain representation of a tag↔asset assignment. +type Assignment struct { + ID string `json:"id" yaml:"id"` + TagID string `json:"tag_id" yaml:"tag_id"` + AssetID string `json:"asset_id" yaml:"asset_id"` + AssetType string `json:"asset_type" yaml:"asset_type"` + PlatformRef string `json:"platform_ref" yaml:"platform_ref"` + CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` +} + +// AssignmentFailure records an asset that could not be assigned. Err keeps the +// full error; Detail and Status are the one-line summary a per-asset report +// shows, since a run of many assets cannot spend a problem document on each. +type AssignmentFailure struct { + AssetID string + Err cenclierrors.CencliError + Detail string + Status mo.Option[int64] +} + +// summarizedError is the part of an API error worth quoting per asset. The +// client's structured error implements it; our own typed per-asset errors do +// not, and fall back to their (already one-line) message. +type summarizedError interface { + Detail() mo.Option[string] + StatusCode() mo.Option[int64] +} + +// newAssignmentFailure records a failed asset, reducing its error to the +// one-line form a per-asset report can display. +func newAssignmentFailure(assetID string, err cenclierrors.CencliError) AssignmentFailure { + failure := AssignmentFailure{AssetID: assetID, Err: err, Detail: err.Error()} + + var summarized summarizedError + if errors.As(err, &summarized) { + if detail := summarized.Detail(); detail.IsPresent() { + failure.Detail = detail.MustGet() + } + failure.Status = summarized.StatusCode() + } + return failure +} + +// perAssetOutcome decides what a continue-on-error run reports alongside its +// per-asset results; Assign and Unassign share it so the two cannot drift. A run +// that got nowhere has nothing to render, so its error is fatal. Otherwise the +// results speak for themselves, and only a mixed run or an interrupt adds a +// partial error - an all-failed run is left for the command to exit non-zero on. +func perAssetOutcome( + succeeded, failed int, + firstErr cenclierrors.CencliError, + summarize func() cenclierrors.CencliError, +) (partial, fatal cenclierrors.CencliError) { + switch { + case succeeded == 0 && failed == 0: + return nil, firstErr + case succeeded > 0 && failed > 0: + return cenclierrors.ToPartialError(summarize()), nil + case failed == 0 && firstErr != nil: + return cenclierrors.ToPartialError(firstErr), nil + default: + return nil, nil + } +} + +// AssignResult is the outcome of assigning a tag to explicit assets. TagID +// echoes the caller's identifier; PartialError is set when some assets +// succeeded and others failed. Assignments and Failures are in input order. +type AssignResult struct { + Meta *responsemeta.ResponseMeta + TagID string + Assignments []Assignment + Failures []AssignmentFailure + PartialError cenclierrors.CencliError +} + +// UnassignResult is the outcome of unassigning a tag from explicit assets. TagID +// echoes the caller's identifier; Unassigned holds the removed assignments (from +// the per-asset lookup, so asset type is available for rendering) and Failures the +// assets that were not assigned or whose removal failed, both in input order. +type UnassignResult struct { + Meta *responsemeta.ResponseMeta + TagID string + Unassigned []Assignment + Failures []AssignmentFailure + PartialError cenclierrors.CencliError +} + +// AssignmentsResult is the outcome of listing a tag's assignments. +type AssignmentsResult struct { + Meta *responsemeta.ResponseMeta + // Empty in streaming mode, where each assignment is emitted as it arrives. + Assignments []Assignment + // TotalSize is the API's count of assignments matching the filters. + TotalSize int64 + // PartialError summarizes an error hit after the first successful page. + PartialError cenclierrors.CencliError +} + +// ListResult is the outcome of listing tags. +type ListResult struct { + Meta *responsemeta.ResponseMeta + // Tags holds the tags fetched across pages, in server order. + Tags []Tag + // TotalSize is the total number of tags visible to the caller (from the API). + TotalSize int64 + // PartialError summarizes an error encountered after the first successful + // page; when present the result carries partial data. + PartialError cenclierrors.CencliError +} diff --git a/internal/app/tags/errors.go b/internal/app/tags/errors.go new file mode 100644 index 0000000..4baee8f --- /dev/null +++ b/internal/app/tags/errors.go @@ -0,0 +1,239 @@ +package tags + +import ( + "fmt" + "strings" + "time" + + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +// invalidPaginationParamsError signals that a pagination parameter (page size or +// max pages) was given an invalid value. +type invalidPaginationParamsError struct { + reason string +} + +// NewInvalidPaginationParamsError creates an invalid-pagination-params error. +func NewInvalidPaginationParamsError(reason string) cenclierrors.CencliError { + return &invalidPaginationParamsError{reason: reason} +} + +func (e *invalidPaginationParamsError) Error() string { return e.reason } + +func (e *invalidPaginationParamsError) Title() string { return "Invalid Pagination Parameters" } + +func (e *invalidPaginationParamsError) ShouldPrintUsage() bool { return true } + +// invalidEnumFilterError signals that a filter flag with a fixed set of accepted +// values (e.g. --order-by, --privacy) was given an unsupported value. +type invalidEnumFilterError struct { + filter string + provided string + supported []string +} + +// NewInvalidEnumFilterError creates an invalid-enum-filter error naming the flag, +// the rejected value, and the accepted set. +func NewInvalidEnumFilterError(filter, provided string, supported []string) cenclierrors.CencliError { + return &invalidEnumFilterError{filter: filter, provided: provided, supported: supported} +} + +func (e *invalidEnumFilterError) Error() string { + return fmt.Sprintf("invalid %s '%s'; supported values: %s", e.filter, e.provided, strings.Join(e.supported, ", ")) +} + +func (e *invalidEnumFilterError) Title() string { return "Invalid Filter Value" } + +func (e *invalidEnumFilterError) ShouldPrintUsage() bool { return true } + +// invalidTimeWindowError signals that a pair of time filters cannot match +// anything, e.g. created-before earlier than created-after. +type invalidTimeWindowError struct { + reason string +} + +// NewInvalidTimeWindowError creates an invalid-time-window error. +func NewInvalidTimeWindowError(reason string) cenclierrors.CencliError { + return &invalidTimeWindowError{reason: reason} +} + +func (e *invalidTimeWindowError) Error() string { return e.reason } + +func (e *invalidTimeWindowError) Title() string { return "Invalid Time Window" } + +func (e *invalidTimeWindowError) ShouldPrintUsage() bool { return true } + +// emptyTagIDError signals that a tag command was given an empty tag identifier +// (name or UUID). Rejected before any lookup so an empty name can never be sent +// to ListTags — which would otherwise match no filter and resolve to an +// arbitrary tag. +type emptyTagIDError struct{} + +// NewEmptyTagIDError creates an empty-tag-identifier error. +func NewEmptyTagIDError() cenclierrors.CencliError { return &emptyTagIDError{} } + +func (e *emptyTagIDError) Error() string { return "a tag name or ID is required" } + +func (e *emptyTagIDError) Title() string { return "Invalid Tag" } + +func (e *emptyTagIDError) ShouldPrintUsage() bool { return true } + +// invalidOperationIDError signals that an operation identifier is not a UUID. +// The endpoint declares operation_id as format:uuid, so anything else is a +// guaranteed 422 — rejected at the boundary instead of after a round trip. +type invalidOperationIDError struct { + provided string +} + +// NewInvalidOperationIDError creates an invalid-operation-ID error. +func NewInvalidOperationIDError(provided string) cenclierrors.CencliError { + return &invalidOperationIDError{provided: provided} +} + +func (e *invalidOperationIDError) Error() string { + if e.provided == "" { + return "an operation ID is required" + } + return fmt.Sprintf("operation ID %q is not a valid UUID", e.provided) +} + +func (e *invalidOperationIDError) Title() string { return "Invalid Operation ID" } + +func (e *invalidOperationIDError) ShouldPrintUsage() bool { return true } + +// operationWaitTimeoutError signals that --wait gave up before the operation +// reached a terminal status. The job keeps running server-side. +type operationWaitTimeoutError struct { + operationID string + status string + timeout time.Duration +} + +// NewOperationWaitTimeoutError creates a wait-timeout error carrying the last +// status seen before giving up. +func NewOperationWaitTimeoutError(operationID, status string, timeout time.Duration) cenclierrors.CencliError { + return &operationWaitTimeoutError{operationID: operationID, status: status, timeout: timeout} +} + +func (e *operationWaitTimeoutError) Error() string { + return fmt.Sprintf( + "timed out after %s waiting for operation %s; it is still %s and continues server-side", + e.timeout, e.operationID, e.status) +} + +func (e *operationWaitTimeoutError) Title() string { return "Timeout" } + +func (e *operationWaitTimeoutError) ShouldPrintUsage() bool { return false } + +// tagNotFoundError signals that a tag name could not be resolved to an existing +// tag during a name→UUID lookup. +type tagNotFoundError struct { + name string +} + +// NewTagNotFoundError creates a tag-not-found error naming the unresolved tag. +func NewTagNotFoundError(name string) cenclierrors.CencliError { + return &tagNotFoundError{name: name} +} + +func (e *tagNotFoundError) Error() string { + return fmt.Sprintf("tag %q not found", e.name) +} + +func (e *tagNotFoundError) Title() string { return "Tag Not Found" } + +func (e *tagNotFoundError) ShouldPrintUsage() bool { return false } + +// invalidTagNameError signals that a tag name was empty or whitespace-only. +type invalidTagNameError struct{} + +// NewInvalidTagNameError creates an invalid-tag-name error. +func NewInvalidTagNameError() cenclierrors.CencliError { return &invalidTagNameError{} } + +func (e *invalidTagNameError) Error() string { return "tag name must not be empty" } + +func (e *invalidTagNameError) Title() string { return "Invalid Tag Name" } + +func (e *invalidTagNameError) ShouldPrintUsage() bool { return true } + +// noAssetsError signals that an assignment command was given no assets to act +// on (a service-level guard; the command layer normally rejects it earlier). +type noAssetsError struct{} + +// NewNoAssetsError creates a no-assets error. +func NewNoAssetsError() cenclierrors.CencliError { return &noAssetsError{} } + +func (e *noAssetsError) Error() string { return "at least one asset is required" } + +func (e *noAssetsError) Title() string { return "No Assets Provided" } + +func (e *noAssetsError) ShouldPrintUsage() bool { return true } + +// emptyQueryError signals that a bulk command was given a blank CenQL query, +// which would otherwise match nothing and waste an operation. +type emptyQueryError struct{} + +// NewEmptyQueryError creates an empty-query error. +func NewEmptyQueryError() cenclierrors.CencliError { return &emptyQueryError{} } + +func (e *emptyQueryError) Error() string { return "--query must not be empty" } + +func (e *emptyQueryError) Title() string { return "Invalid Query" } + +func (e *emptyQueryError) ShouldPrintUsage() bool { return true } + +// assignPartialError summarizes an assign run where some assets failed. +type assignPartialError struct { + failed int + total int +} + +func newAssignPartialError(failed, total int) cenclierrors.CencliError { + return &assignPartialError{failed: failed, total: total} +} + +func (e *assignPartialError) Error() string { + return fmt.Sprintf("%d of %d asset(s) failed to assign", e.failed, e.total) +} + +func (e *assignPartialError) Title() string { return "Some Assets Failed to Assign" } + +func (e *assignPartialError) ShouldPrintUsage() bool { return false } + +// unassignPartialError summarizes an unassign run where some assets failed. +type unassignPartialError struct { + failed int + total int +} + +func newUnassignPartialError(failed, total int) cenclierrors.CencliError { + return &unassignPartialError{failed: failed, total: total} +} + +func (e *unassignPartialError) Error() string { + return fmt.Sprintf("%d of %d asset(s) failed to unassign", e.failed, e.total) +} + +func (e *unassignPartialError) Title() string { return "Some Assets Failed to Unassign" } + +func (e *unassignPartialError) ShouldPrintUsage() bool { return false } + +// assetNotAssignedError signals that an asset has no assignment to the tag, so +// there is nothing to remove. Recorded as a per-asset failure during unassign. +type assetNotAssignedError struct { + assetID string +} + +// NewAssetNotAssignedError creates an asset-not-assigned error naming the asset. +func NewAssetNotAssignedError(assetID string) cenclierrors.CencliError { + return &assetNotAssignedError{assetID: assetID} +} + +func (e *assetNotAssignedError) Error() string { + return fmt.Sprintf("asset %q is not assigned to this tag", e.assetID) +} + +func (e *assetNotAssignedError) Title() string { return "Asset Not Assigned" } + +func (e *assetNotAssignedError) ShouldPrintUsage() bool { return false } diff --git a/internal/app/tags/operations.go b/internal/app/tags/operations.go new file mode 100644 index 0000000..b95fb1c --- /dev/null +++ b/internal/app/tags/operations.go @@ -0,0 +1,348 @@ +package tags + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + utilconvert "github.com/censys/cencli/internal/pkg/convertutil" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +const ( + // allTagsSelector lists operations across every tag in the organization. + // Only the list endpoint accepts it; get and cancel require a tag UUID. + allTagsSelector = "-" + + // Poll pacing for WaitForOperation: start responsive, then back off so a + // long-running job does not burn requests. + initialPollInterval = 2 * time.Second + maxPollInterval = 15 * time.Second +) + +// ListOperations lists the asynchronous bulk jobs for one tag, or for every tag +// in the organization when no tag is given. +func (s *tagsService) ListOperations( + ctx context.Context, + params OperationsParams, +) (OperationsResult, cenclierrors.CencliError) { + // validate filter enums against the API contract before making any request + if err := validateOperationType(params.Type); err != nil { + return OperationsResult{}, err + } + if err := validateOperationStatus(params.Status); err != nil { + return OperationsResult{}, err + } + if err := validateOperationsOrderBy(params.OrderBy); err != nil { + return OperationsResult{}, err + } + + // handle pagination invariants + if err := validatePaginationParams(params.PageSize, params.MaxPages); err != nil { + return OperationsResult{}, err + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + pageSize := optionalInt64(params.PageSize) + + listPage := func(tagID string) (paginated[TagOperation], cenclierrors.CencliError) { + listFn := func(pageToken mo.Option[string]) (client.Result[components.TagOperationsList], client.ClientError) { + return s.client.ListTagOperations(ctx, client.ListTagOperationsRequest{ + OrgID: orgIDStr, + TagID: tagID, + Type: params.Type, + Status: params.Status, + OrderBy: params.OrderBy, + PageSize: pageSize, + PageToken: pageToken, + }) + } + return paginate(ctx, params.MaxPages, "operations", listFn, extractOperationsPage) + } + + // An absent tag lists org-wide, which needs no resolution at all. + var page paginated[TagOperation] + var err cenclierrors.CencliError + if params.TagID.IsPresent() { + page, err = callWithTag(ctx, s, orgIDStr, params.TagID.MustGet(), listPage) + } else { + page, err = listPage(allTagsSelector) + } + if err != nil { + return OperationsResult{}, err + } + + return OperationsResult{ + Meta: page.Meta, + Operations: page.Items, + TotalSize: page.TotalSize, + PartialError: page.PartialError, + }, nil +} + +// GetOperation retrieves a single bulk tag operation. Both path parameters are +// UUID-only, so a tag name is resolved first. +func (s *tagsService) GetOperation( + ctx context.Context, + params GetOperationParams, +) (GetOperationResult, cenclierrors.CencliError) { + orgIDStr := utilconvert.OptionalString(params.OrgID) + + operationID, err := requireOperationUUID(params.OperationID) + if err != nil { + return GetOperationResult{}, err + } + + result, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (client.Result[components.TagOperation], cenclierrors.CencliError) { + return s.client.GetTagOperation(ctx, orgIDStr, tagID, operationID) + }) + if err != nil { + return GetOperationResult{}, err + } + + return newGetOperationResult(result), nil +} + +// CancelOperation asks the API to stop a running bulk job. Work already +// committed before the cancellation is kept, so this narrows a job rather than +// undoing it. Both path parameters are UUID-only, so a tag name is resolved first. +func (s *tagsService) CancelOperation( + ctx context.Context, + params CancelOperationParams, +) (CancelOperationResult, cenclierrors.CencliError) { + orgIDStr := utilconvert.OptionalString(params.OrgID) + + operationID, err := requireOperationUUID(params.OperationID) + if err != nil { + return CancelOperationResult{}, err + } + + result, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (client.Result[components.TagOperation], cenclierrors.CencliError) { + return s.client.CancelTagOperation(ctx, orgIDStr, tagID, operationID) + }) + if err != nil { + return CancelOperationResult{}, err + } + + meta, operation := mapOperationResult(result) + return CancelOperationResult{Meta: meta, Operation: operation}, nil +} + +// WaitForOperation polls an operation until it reaches a terminal status, the +// optional timeout expires, or the context is cancelled. A terminal status is +// not an error: every outcome comes back as a result, and the caller decides +// what it means for the exit code. +func (s *tagsService) WaitForOperation( + ctx context.Context, + params WaitParams, +) (GetOperationResult, cenclierrors.CencliError) { + orgIDStr := utilconvert.OptionalString(params.OrgID) + + // Resolve once up front rather than per poll, so a name costs one extra + // request for the whole wait instead of one per tick. + tagID, operationID, err := s.resolveOperationTarget(ctx, orgIDStr, params.TagID, params.OperationID) + if err != nil { + return GetOperationResult{}, err + } + + pollCtx := ctx + if params.Timeout.IsPresent() { + var cancel context.CancelFunc + pollCtx, cancel = context.WithTimeout(ctx, params.Timeout.MustGet()) + defer cancel() + } + + interval := initialPollInterval + lastStatus := "" + retriedByName := false + + for { + result, getErr := s.client.GetTagOperation(pollCtx, orgIDStr, tagID, operationID) + if getErr != nil { + // The parent going away outranks the timeout: report what actually + // stopped us rather than whichever context observed it first. + if waitErr := s.waitContextError(ctx, pollCtx, operationID, lastStatus, params.Timeout); waitErr != nil { + return GetOperationResult{}, waitErr + } + // The tag may be one whose name is UUID-shaped. Retry against the tag + // of that name and carry on polling the corrected ID - but only once, + // since the lookup would keep returning the same answer and spin. + if !retriedByName { + if retry := s.resolveNameCollision(pollCtx, orgIDStr, params.TagID, getErr); retry.IsPresent() { + tagID = retry.MustGet() + retriedByName = true + continue + } + } + return GetOperationResult{}, getErr + } + + out := newGetOperationResult(result) + lastStatus = out.Operation.Status + + if isTerminalStatus(out.Operation.Status) { + return out, nil + } + + reportOperationProgress(pollCtx, out.Operation) + + if sleepErr := s.sleep(pollCtx, interval); sleepErr != nil { + if waitErr := s.waitContextError(ctx, pollCtx, operationID, lastStatus, params.Timeout); waitErr != nil { + return GetOperationResult{}, waitErr + } + return GetOperationResult{}, cenclierrors.ParseContextError(sleepErr) + } + + interval = min(interval*2, maxPollInterval) + } +} + +// waitContextError explains why polling stopped, preferring the caller's own +// cancellation over an expired wait timeout. It returns nil when neither +// context is done, leaving the underlying error to speak for itself. +func (s *tagsService) waitContextError( + ctx, pollCtx context.Context, + operationID, lastStatus string, + timeout mo.Option[time.Duration], +) cenclierrors.CencliError { + if parentErr := ctx.Err(); parentErr != nil { + return cenclierrors.ParseContextError(parentErr) + } + if pollCtx.Err() != nil && timeout.IsPresent() { + status := lastStatus + if status == "" { + status = "unfinished" + } + return NewOperationWaitTimeoutError(operationID, status, timeout.MustGet()) + } + return nil +} + +// requireOperationUUID rejects an operation ID the endpoints could not accept, +// before any request is spent on it. +func requireOperationUUID(operationID string) (string, cenclierrors.CencliError) { + if _, err := uuid.Parse(operationID); err != nil { + return "", NewInvalidOperationIDError(operationID) + } + return operationID, nil +} + +// resolveOperationTarget turns a caller-supplied tag identifier and operation ID +// into the UUID pair the operation endpoints require. Used by the wait loop, +// which resolves once and then polls; the single-request verbs go through +// callWithTag instead so they also get the tag-name fallback. +func (s *tagsService) resolveOperationTarget( + ctx context.Context, + orgID mo.Option[string], + tagID identifiers.TagID, + operationID string, +) (string, string, cenclierrors.CencliError) { + id, err := requireOperationUUID(operationID) + if err != nil { + return "", "", err + } + + resolved, err := s.resolveTagID(ctx, orgID, tagID) + if err != nil { + return "", "", err + } + return resolved, id, nil +} + +// isTerminalStatus reports whether an operation has finished, for any outcome. +func isTerminalStatus(status string) bool { + switch components.TagOperationStatus(status) { + case components.TagOperationStatusSucceeded, + components.TagOperationStatusLimitReached, + components.TagOperationStatusFailed, + components.TagOperationStatusCancelled: + return true + default: + return false + } +} + +// reportOperationProgress surfaces how far a running operation has got. The +// total is approximate for bulk_create, so it is only shown when known. +func reportOperationProgress(ctx context.Context, op TagOperation) { + msg := fmt.Sprintf("Operation %s: %d processed", op.Status, op.ProcessedCount) + if op.TotalCount > 0 { + msg = fmt.Sprintf("Operation %s: %d/%d processed (approx)", + op.Status, op.ProcessedCount, op.TotalCount) + } + progress.ReportMessage(ctx, progress.StageProcess, msg) +} + +// newGetOperationResult builds the single-operation result from a client call. +func newGetOperationResult(result client.Result[components.TagOperation]) GetOperationResult { + meta, operation := mapOperationResult(result) + return GetOperationResult{Meta: meta, Operation: operation} +} + +// mapOperationResult unpacks a client response carrying one operation. Shared by +// every endpoint that answers with a TagOperation: get, wait, and bulk submit. +func mapOperationResult( + result client.Result[components.TagOperation], +) (*responsemeta.ResponseMeta, TagOperation) { + var meta *responsemeta.ResponseMeta + if result.Metadata.Request != nil || result.Metadata.Response != nil { + meta = responsemeta.NewResponseMeta( + result.Metadata.Request, + result.Metadata.Response, + result.Metadata.Latency, + result.Metadata.Attempts, + ) + } + + var operation TagOperation + if result.Data != nil { + operation = mapTagOperation(*result.Data) + } + return meta, operation +} + +// extractOperationsPage adapts an operations list envelope for the paginator. +func extractOperationsPage(list *components.TagOperationsList) pageData[TagOperation] { + items := make([]TagOperation, 0, len(list.Operations)) + for _, op := range list.Operations { + items = append(items, mapTagOperation(op)) + } + + nextPageToken := "" + if npt := list.GetNextPageToken(); npt != nil { + nextPageToken = *npt + } + + return pageData[TagOperation]{Items: items, TotalSize: list.TotalSize, NextPageToken: nextPageToken} +} + +// mapTagOperation converts an SDK tag operation into the domain DTO. +func mapTagOperation(op components.TagOperation) TagOperation { + return TagOperation{ + ID: op.ID, + TagID: op.TagID, + TagName: op.TagName, + Type: string(op.Type), + Status: string(op.Status), + Query: op.Query, + TotalCount: op.TotalCount, + ProcessedCount: op.ProcessedCount, + SuccessfulCount: op.SuccessfulCount, + StatusMessage: op.StatusMessage, + ErrorMessage: op.ErrorMessage, + CreatedAt: op.CreatedAt, + EndedAt: op.EndedAt, + } +} diff --git a/internal/app/tags/operations_test.go b/internal/app/tags/operations_test.go new file mode 100644 index 0000000..b3d0223 --- /dev/null +++ b/internal/app/tags/operations_test.go @@ -0,0 +1,674 @@ +package tags + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/gen/client/mocks" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" +) + +const ( + testTagUUID = "a6217129-be72-4b02-a42c-9c431574e524" + testOpUUID = "d421a231-eb5e-4927-a0be-8aa749eb731c" +) + +func operationsPage(ids []string, status components.TagOperationStatus, total int64, nextToken string, +) client.Result[components.TagOperationsList] { + ops := make([]components.TagOperation, 0, len(ids)) + for _, id := range ids { + ops = append(ops, components.TagOperation{ + ID: id, + TagID: testTagUUID, + Type: components.TagOperationTypeBulkCreate, + Status: status, + }) + } + list := &components.TagOperationsList{Operations: ops, TotalSize: total} + if nextToken != "" { + list.NextPageToken = strPtr(nextToken) + } + return client.Result[components.TagOperationsList]{Metadata: okMeta(), Data: list} +} + +func operationResult(status components.TagOperationStatus, processed int64) client.Result[components.TagOperation] { + return client.Result[components.TagOperation]{ + Metadata: okMeta(), + Data: &components.TagOperation{ + ID: testOpUUID, + TagID: testTagUUID, + TagName: "my-tag", + Type: components.TagOperationTypeBulkCreate, + Status: status, + ProcessedCount: processed, + TotalCount: 100, + }, + } +} + +func TestTagsService_ListOperations(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params OperationsParams + assert func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) + }{ + { + name: "no tag lists org-wide without resolving anything", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + // Org-wide listing must never trigger a name lookup. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTagOperations(gomock.Any(), client.ListTagOperationsRequest{TagID: "-"}). + Return(operationsPage([]string{"op-1"}, components.TagOperationStatusRunning, 1, ""), nil) + return m + }, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Operations, 1) + require.Equal(t, "op-1", res.Operations[0].ID) + require.Equal(t, "running", res.Operations[0].Status) + require.Equal(t, "bulk_create", res.Operations[0].Type) + require.NotNil(t, res.Meta) + }, + }, + { + name: "UUID tag skips resolution", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + // The durable guard: a UUID must cost zero lookup requests. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTagOperations(gomock.Any(), client.ListTagOperationsRequest{TagID: testTagUUID}). + Return(operationsPage([]string{"op-1"}, components.TagOperationStatusSucceeded, 1, ""), nil) + return m + }, + params: OperationsParams{TagID: mo.Some(identifiers.NewTagID(testTagUUID))}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Operations, 1) + }, + }, + { + name: "name is resolved to a UUID before listing", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{{ID: testTagUUID, Name: "my-tag"}}, TotalSize: 1}, + }, nil) + m.EXPECT().ListTagOperations(gomock.Any(), client.ListTagOperationsRequest{TagID: testTagUUID}). + Return(operationsPage([]string{"op-1"}, components.TagOperationStatusSucceeded, 1, ""), nil) + return m + }, + params: OperationsParams{TagID: mo.Some(identifiers.NewTagID("my-tag"))}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Operations, 1) + }, + }, + { + name: "filters and org are threaded to the client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagOperations(gomock.Any(), client.ListTagOperationsRequest{ + OrgID: mo.Some("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + TagID: "-", + Type: mo.Some("bulk_delete"), + Status: mo.Some("failed"), + OrderBy: mo.Some("create_time_asc"), + PageSize: mo.Some(int64(50)), + }).Return(operationsPage(nil, components.TagOperationStatusFailed, 0, ""), nil) + return m + }, + params: OperationsParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"))), + Type: mo.Some("bulk_delete"), + Status: mo.Some("failed"), + OrderBy: mo.Some("create_time_asc"), + PageSize: mo.Some(uint64(50)), + }, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Empty(t, res.Operations) + }, + }, + { + name: "pagination collects across pages", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTagOperations(gomock.Any(), client.ListTagOperationsRequest{TagID: "-"}). + Return(operationsPage([]string{"op-1"}, components.TagOperationStatusSucceeded, 2, "token1"), nil), + m.EXPECT().ListTagOperations(gomock.Any(), client.ListTagOperationsRequest{ + TagID: "-", PageToken: mo.Some("token1"), + }).Return(operationsPage([]string{"op-2"}, components.TagOperationStatusSucceeded, 2, ""), nil), + ) + return m + }, + params: OperationsParams{MaxPages: mo.None[uint64]()}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Operations, 2) + require.Equal(t, "op-2", res.Operations[1].ID) + }, + }, + { + name: "later page failure returns collected data with a partial error", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTagOperations(gomock.Any(), gomock.Any()). + Return(operationsPage([]string{"op-1"}, components.TagOperationStatusSucceeded, 2, "token1"), nil), + m.EXPECT().ListTagOperations(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagOperationsList]{}, client.NewClientError(errors.New("boom"))), + ) + return m + }, + params: OperationsParams{MaxPages: mo.None[uint64]()}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Operations, 1) + require.NotNil(t, res.PartialError) + }, + }, + { + name: "invalid type is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagOperations(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: OperationsParams{Type: mo.Some("bogus")}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "invalid status is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagOperations(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: OperationsParams{Status: mo.Some("in_progress")}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "in_progress") + }, + }, + { + name: "invalid order-by is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagOperations(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: OperationsParams{OrderBy: mo.Some("name_asc")}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.Error(t, err) + }, + }, + { + name: "zero page size is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagOperations(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: OperationsParams{PageSize: mo.Some(uint64(0))}, + assert: func(t *testing.T, res OperationsResult, err cenclierrors.CencliError) { + require.Error(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.ListOperations(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +func TestTagsService_GetOperation(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params GetOperationParams + assert func(t *testing.T, res GetOperationResult, err cenclierrors.CencliError) + }{ + { + name: "UUID tag skips resolution and maps the operation", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().GetTagOperation(gomock.Any(), mo.None[string](), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusSucceeded, 100), nil) + return m + }, + params: GetOperationParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: testOpUUID}, + assert: func(t *testing.T, res GetOperationResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, testOpUUID, res.Operation.ID) + require.Equal(t, "succeeded", res.Operation.Status) + require.Equal(t, int64(100), res.Operation.ProcessedCount) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name is resolved before the get", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{{ID: testTagUUID, Name: "my-tag"}}, TotalSize: 1}, + }, nil) + m.EXPECT().GetTagOperation(gomock.Any(), mo.None[string](), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusRunning, 5), nil) + return m + }, + params: GetOperationParams{TagID: identifiers.NewTagID("my-tag"), OperationID: testOpUUID}, + assert: func(t *testing.T, res GetOperationResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "running", res.Operation.Status) + }, + }, + { + name: "non-UUID operation ID is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: GetOperationParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: "not-a-uuid"}, + assert: func(t *testing.T, res GetOperationResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.True(t, err.ShouldPrintUsage()) + require.Contains(t, err.Error(), "not-a-uuid") + }, + }, + { + name: "empty tag is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: GetOperationParams{TagID: identifiers.NewTagID(""), OperationID: testOpUUID}, + assert: func(t *testing.T, res GetOperationResult, err cenclierrors.CencliError) { + require.Error(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.GetOperation(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +func TestTagsService_CancelOperation(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params CancelOperationParams + assert func(t *testing.T, res CancelOperationResult, err cenclierrors.CencliError) + }{ + { + name: "UUID tag skips resolution and maps the cancelled operation", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().CancelTagOperation(gomock.Any(), mo.None[string](), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusCancelled, 40), nil) + return m + }, + params: CancelOperationParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: testOpUUID}, + assert: func(t *testing.T, res CancelOperationResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, testOpUUID, res.Operation.ID) + require.Equal(t, "cancelled", res.Operation.Status) + require.Equal(t, int64(40), res.Operation.ProcessedCount) + require.NotNil(t, res.Meta) + }, + }, + { + // The API answers with the operation as it stood when cancellation was + // accepted, which can still be running while the job winds down. + name: "a still-running operation comes back as a result, not an error", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CancelTagOperation(gomock.Any(), mo.None[string](), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusRunning, 12), nil) + return m + }, + params: CancelOperationParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: testOpUUID}, + assert: func(t *testing.T, res CancelOperationResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "running", res.Operation.Status) + }, + }, + { + name: "name is resolved before the cancel", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + bulkNameLookup(m, "my-tag") + m.EXPECT().CancelTagOperation(gomock.Any(), mo.None[string](), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusCancelled, 40), nil) + return m + }, + params: CancelOperationParams{TagID: identifiers.NewTagID("my-tag"), OperationID: testOpUUID}, + assert: func(t *testing.T, res CancelOperationResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "cancelled", res.Operation.Status) + }, + }, + { + name: "org id is threaded through to the request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CancelTagOperation( + gomock.Any(), mo.Some(bulkOrgUUID.String()), testTagUUID, testOpUUID, + ).Return(operationResult(components.TagOperationStatusCancelled, 40), nil) + return m + }, + params: CancelOperationParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(bulkOrgUUID)), + TagID: identifiers.NewTagID(testTagUUID), + OperationID: testOpUUID, + }, + assert: func(t *testing.T, res CancelOperationResult, err cenclierrors.CencliError) { + require.NoError(t, err) + }, + }, + { + name: "non-UUID operation ID is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().CancelTagOperation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: CancelOperationParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: "not-a-uuid"}, + assert: func(t *testing.T, _ CancelOperationResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.True(t, err.ShouldPrintUsage()) + require.Contains(t, err.Error(), "not-a-uuid") + }, + }, + { + name: "empty tag is rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().CancelTagOperation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: CancelOperationParams{TagID: identifiers.NewTagID(""), OperationID: testOpUUID}, + assert: func(t *testing.T, _ CancelOperationResult, err cenclierrors.CencliError) { + require.Error(t, err) + }, + }, + { + // A finished job cannot be cancelled; the API says so with a 409. + name: "client error is returned as-is", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CancelTagOperation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + client.Result[components.TagOperation]{}, + clientStructuredError("Operation already finished", 409), + ) + return m + }, + params: CancelOperationParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: testOpUUID}, + assert: func(t *testing.T, _ CancelOperationResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "Operation already finished") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.CancelOperation(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +// newWaitService builds a service whose poll loop records the delays it was +// asked to sleep for instead of actually waiting. +func newWaitService(c client.Client, delays *[]time.Duration) Service { + return &tagsService{ + client: c, + sleep: func(ctx context.Context, d time.Duration) error { + *delays = append(*delays, d) + return ctx.Err() + }, + } +} + +func TestTagsService_WaitForOperation(t *testing.T) { + waitParams := WaitParams{TagID: identifiers.NewTagID(testTagUUID), OperationID: testOpUUID} + + // Every terminal status ends the poll and comes back as a result, never as + // an error: the command layer owns what each outcome means. + terminalStatuses := []components.TagOperationStatus{ + components.TagOperationStatusSucceeded, + components.TagOperationStatusLimitReached, + components.TagOperationStatusFailed, + components.TagOperationStatusCancelled, + } + + for _, status := range terminalStatuses { + t.Run("returns immediately on "+string(status), func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(status, 100), nil).Times(1) + + var delays []time.Duration + res, err := newWaitService(m, &delays).WaitForOperation(context.Background(), waitParams) + + require.NoError(t, err) + require.Equal(t, string(status), res.Operation.Status) + require.Empty(t, delays, "a terminal status must not sleep") + }) + } + + t.Run("polls through pending and running to a terminal status", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusPending, 0), nil), + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusRunning, 50), nil), + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusSucceeded, 100), nil), + ) + + var delays []time.Duration + res, err := newWaitService(m, &delays).WaitForOperation(context.Background(), waitParams) + + require.NoError(t, err) + require.Equal(t, "succeeded", res.Operation.Status) + require.Equal(t, []time.Duration{2 * time.Second, 4 * time.Second}, delays) + }) + + t.Run("backoff doubles and caps at the maximum", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + // Eight running polls, then done: long enough to reach and hold the cap. + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusRunning, 1), nil).Times(8) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusSucceeded, 100), nil) + + var delays []time.Duration + _, err := newWaitService(m, &delays).WaitForOperation(context.Background(), waitParams) + + require.NoError(t, err) + require.Equal(t, []time.Duration{ + 2 * time.Second, 4 * time.Second, 8 * time.Second, + 15 * time.Second, 15 * time.Second, 15 * time.Second, + 15 * time.Second, 15 * time.Second, + }, delays) + }) + + t.Run("resolves the tag name once, not once per poll", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(1). + Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{{ID: testTagUUID, Name: "my-tag"}}, TotalSize: 1}, + }, nil) + gomock.InOrder( + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusRunning, 1), nil), + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusSucceeded, 100), nil), + ) + + var delays []time.Duration + _, err := newWaitService(m, &delays).WaitForOperation(context.Background(), WaitParams{ + TagID: identifiers.NewTagID("my-tag"), OperationID: testOpUUID, + }) + + require.NoError(t, err) + }) + + t.Run("context cancelled mid-poll reports an interruption", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx, cancel := context.WithCancel(context.Background()) + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + DoAndReturn(func(context.Context, mo.Option[string], string, string) (client.Result[components.TagOperation], client.ClientError) { + // Cancel while "in flight", so the sleep that follows observes it. + cancel() + return operationResult(components.TagOperationStatusRunning, 1), nil + }) + + var delays []time.Duration + _, err := newWaitService(m, &delays).WaitForOperation(ctx, waitParams) + + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + require.True(t, cenclierrors.IsInterrupted(err)) + }) + + t.Run("expired timeout reports a wait timeout carrying the last status", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + Return(operationResult(components.TagOperationStatusRunning, 1), nil).AnyTimes() + + // A timeout this small is already expired by the first sleep. + svc := &tagsService{ + client: m, + sleep: func(ctx context.Context, _ time.Duration) error { + <-ctx.Done() + return ctx.Err() + }, + } + + _, err := svc.WaitForOperation(context.Background(), WaitParams{ + TagID: identifiers.NewTagID(testTagUUID), + OperationID: testOpUUID, + Timeout: mo.Some(10 * time.Millisecond), + }) + + require.Error(t, err) + require.False(t, err.ShouldPrintUsage()) + require.Equal(t, "Timeout", err.Title()) + require.Contains(t, err.Error(), "running") + require.Contains(t, err.Error(), testOpUUID) + }) + + t.Run("caller cancellation outranks an expired timeout", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx, cancel := context.WithCancel(context.Background()) + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), testTagUUID, testOpUUID). + DoAndReturn(func(context.Context, mo.Option[string], string, string) (client.Result[components.TagOperation], client.ClientError) { + cancel() + return operationResult(components.TagOperationStatusRunning, 1), nil + }) + + var delays []time.Duration + _, err := newWaitService(m, &delays).WaitForOperation(ctx, WaitParams{ + TagID: identifiers.NewTagID(testTagUUID), + OperationID: testOpUUID, + Timeout: mo.Some(time.Hour), + }) + + // Ctrl-C must read as an interruption, not as a timeout. + require.Error(t, err) + require.True(t, cenclierrors.IsInterrupted(err)) + }) + + t.Run("non-UUID operation ID is rejected before polling", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTagOperation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + var delays []time.Duration + _, err := newWaitService(m, &delays).WaitForOperation(context.Background(), WaitParams{ + TagID: identifiers.NewTagID(testTagUUID), OperationID: "nope", + }) + + require.Error(t, err) + require.True(t, err.ShouldPrintUsage()) + }) +} diff --git a/internal/app/tags/paginate.go b/internal/app/tags/paginate.go new file mode 100644 index 0000000..18fd8c7 --- /dev/null +++ b/internal/app/tags/paginate.go @@ -0,0 +1,169 @@ +package tags + +import ( + "context" + "fmt" + "time" + + "github.com/samber/mo" + + "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +// pageData is what the paginator needs from one API page, pulled out of an +// endpoint-specific list envelope by the caller's extract function. +type pageData[Item any] struct { + Items []Item + TotalSize int64 + NextPageToken string +} + +// paginated is the outcome of a paginated fetch. Items is empty in streaming +// mode, where each item is emitted as it arrives instead of being collected. +type paginated[Item any] struct { + Meta *responsemeta.ResponseMeta + Items []Item + TotalSize int64 + PartialError cenclierrors.CencliError +} + +// paginate walks a list endpoint page by page until it runs out of pages or hits +// maxPages (absent = all pages), emitting items in streaming mode and collecting +// them otherwise. label names the items in progress messages ("tags"). +// +// A failure on the first page — or a cancellation before any page landed — is a +// hard error; later failures return the pages gathered so far with the error as +// PartialError. +func paginate[Page, Item any]( + ctx context.Context, + maxPages mo.Option[uint64], + label string, + fetch func(pageToken mo.Option[string]) (client.Result[Page], client.ClientError), + extract func(*Page) pageData[Item], +) (paginated[Item], cenclierrors.CencliError) { + var items []Item + // len(items) stays 0 while streaming, so progress needs its own counter. + var collected int + var totalSize int64 + var lastMeta *responsemeta.ResponseMeta + var pagesProcessed uint64 + var firstError cenclierrors.CencliError + pageToken := mo.None[string]() + + start := time.Now() + + // finalize stamps elapsed time and page count onto the last page's metadata, + // which is what the command layer renders. + finalize := func() { + if lastMeta != nil { + lastMeta.Latency = time.Since(start) + lastMeta.PageCount = pagesProcessed + } + } + + for { + if maxPages.IsPresent() && pagesProcessed >= maxPages.MustGet() { + break + } + + if err := ctx.Err(); err != nil { + contextErr := cenclierrors.ParseContextError(err) + if pagesProcessed > 0 { + finalize() + return paginated[Item]{ + Meta: lastMeta, + Items: items, + TotalSize: totalSize, + PartialError: cenclierrors.ToPartialError(contextErr), + }, nil + } + return paginated[Item]{}, contextErr + } + + reportPageProgress(ctx, label, pagesProcessed, collected, maxPages) + + result, err := fetch(pageToken) + if err != nil { + if pagesProcessed == 0 { + return paginated[Item]{}, err + } + firstError = err + progress.ReportError(ctx, progress.StageFetch, err) + break + } + + if result.Metadata.Request != nil || result.Metadata.Response != nil { + lastMeta = responsemeta.NewResponseMeta(result.Metadata.Request, result.Metadata.Response, 0, result.Metadata.Attempts) + } + + if result.Data == nil { + pagesProcessed++ + break + } + + page := extract(result.Data) + for _, item := range page.Items { + emitted, emitErr := streaming.EmitOrCollect(ctx, item, items) + if emitErr != nil { + // The consumer is gone; keep what was emitted and report why. + finalize() + return paginated[Item]{ + Meta: lastMeta, + Items: items, + TotalSize: page.TotalSize, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(emitErr)), + }, nil + } + items = emitted + collected++ + } + totalSize = page.TotalSize + pagesProcessed++ + + if page.NextPageToken == "" || len(page.Items) == 0 { + break + } + + // A server echoing back the token it was given would otherwise loop + // forever under --max-pages=-1, spending a request per turn. + if pageToken.IsPresent() && page.NextPageToken == pageToken.MustGet() { + break + } + + if maxPages.IsPresent() && pagesProcessed >= maxPages.MustGet() { + break + } + + pageToken = mo.Some(page.NextPageToken) + } + + finalize() + + return paginated[Item]{ + Meta: lastMeta, + Items: items, + TotalSize: totalSize, + PartialError: cenclierrors.ToPartialError(firstError), + }, nil +} + +// reportPageProgress reports progress from the second page onwards; the initial +// message comes from the command layer. +func reportPageProgress(ctx context.Context, label string, page uint64, collected int, maxPages mo.Option[uint64]) { + if page == 0 { + return + } + + var msg string + if maxPages.IsPresent() { + msg = fmt.Sprintf("Fetching %s (page %d/%d, %d collected)...", label, page+1, maxPages.MustGet(), collected) + } else { + msg = fmt.Sprintf("Fetching %s (page %d, %d collected)...", label, page+1, collected) + } + + progress.ReportMessage(ctx, progress.StageFetch, msg) +} diff --git a/internal/app/tags/service.go b/internal/app/tags/service.go new file mode 100644 index 0000000..1d829f4 --- /dev/null +++ b/internal/app/tags/service.go @@ -0,0 +1,756 @@ +package tags + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/samber/mo" + + "github.com/censys/censys-sdk-go/models/components" + + "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + utilconvert "github.com/censys/cencli/internal/pkg/convertutil" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +//go:generate mockgen -destination=../../../gen/app/tags/mocks/tagsservice_mock.go -package=mocks -mock_names Service=MockTagsService . Service + +// Service provides tag management capabilities. +type Service interface { + ListTags(ctx context.Context, params ListParams) (ListResult, cenclierrors.CencliError) + GetTag(ctx context.Context, params GetParams) (GetResult, cenclierrors.CencliError) + CreateTag(ctx context.Context, params CreateParams) (CreateResult, cenclierrors.CencliError) + UpdateTag(ctx context.Context, params UpdateParams) (UpdateResult, cenclierrors.CencliError) + DeleteTag(ctx context.Context, params DeleteParams) (DeleteResult, cenclierrors.CencliError) + Assign(ctx context.Context, params AssignParams) (AssignResult, cenclierrors.CencliError) + BulkAssign(ctx context.Context, params BulkAssignParams) (BulkAssignResult, cenclierrors.CencliError) + Unassign(ctx context.Context, params UnassignParams) (UnassignResult, cenclierrors.CencliError) + BulkUnassign(ctx context.Context, params BulkUnassignParams) (BulkUnassignResult, cenclierrors.CencliError) + ListAssignments(ctx context.Context, params AssignmentsParams) (AssignmentsResult, cenclierrors.CencliError) + ListOperations(ctx context.Context, params OperationsParams) (OperationsResult, cenclierrors.CencliError) + GetOperation(ctx context.Context, params GetOperationParams) (GetOperationResult, cenclierrors.CencliError) + CancelOperation(ctx context.Context, params CancelOperationParams) (CancelOperationResult, cenclierrors.CencliError) + WaitForOperation(ctx context.Context, params WaitParams) (GetOperationResult, cenclierrors.CencliError) +} + +type tagsService struct { + client client.Client + // sleep paces the WaitForOperation poll loop. It is a field so tests can + // substitute a fake clock; the repo has no shared clock abstraction. + sleep func(ctx context.Context, d time.Duration) error +} + +func New(client client.Client) Service { + return &tagsService{client: client, sleep: sleepWithContext} +} + +// sleepWithContext waits for d, returning early if the context is cancelled. +// Mirrors the timer/select pattern the client's retry loop uses. +func sleepWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *tagsService) ListTags( + ctx context.Context, + params ListParams, +) (ListResult, cenclierrors.CencliError) { + orgIDStr := utilconvert.OptionalString(params.OrgID) + + // validate filter enums against the API contract before making any request + if err := validateOrderBy(params.OrderBy); err != nil { + return ListResult{}, err + } + if err := validatePrivacy(params.Privacy); err != nil { + return ListResult{}, err + } + + // handle pagination invariants + if err := validatePaginationParams(params.PageSize, params.MaxPages); err != nil { + return ListResult{}, err + } + + pageSize := optionalInt64(params.PageSize) + + listFn := func(pageToken mo.Option[string]) (client.Result[components.TagsList], client.ClientError) { + return s.client.ListTags(ctx, client.ListTagsRequest{ + OrgID: orgIDStr, + PageSize: pageSize, + PageToken: pageToken, + OrderBy: params.OrderBy, + Name: params.Name, + CreatedBy: params.CreatedBy, + Privacy: params.Privacy, + }) + } + + page, err := paginate(ctx, params.MaxPages, "tags", listFn, extractTagsPage) + if err != nil { + return ListResult{}, err + } + + return ListResult{ + Meta: page.Meta, + Tags: page.Items, + TotalSize: page.TotalSize, + PartialError: page.PartialError, + }, nil +} + +// ListAssignments lists the assets a tag is assigned to. The endpoint is keyed by +// UUID, so a name is resolved first. +func (s *tagsService) ListAssignments( + ctx context.Context, + params AssignmentsParams, +) (AssignmentsResult, cenclierrors.CencliError) { + // validate filter enums against the API contract before making any request + if err := validateAssignmentsOrderBy(params.OrderBy); err != nil { + return AssignmentsResult{}, err + } + if err := validateAssetType(params.AssetType); err != nil { + return AssignmentsResult{}, err + } + if err := ValidateTimeWindow(params.CreatedBefore, params.CreatedAfter); err != nil { + return AssignmentsResult{}, err + } + + // handle pagination invariants + if err := validatePaginationParams(params.PageSize, params.MaxPages); err != nil { + return AssignmentsResult{}, err + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + pageSize := optionalInt64(params.PageSize) + + // paginate only returns a hard error when the *first* page failed, so a retry + // here cannot re-emit anything already streamed. + page, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (paginated[Assignment], cenclierrors.CencliError) { + listFn := func(pageToken mo.Option[string]) (client.Result[components.TagAssignmentsList], client.ClientError) { + return s.client.ListTagAssignments(ctx, client.ListTagAssignmentsRequest{ + OrgID: orgIDStr, + TagID: tagID, + AssetID: params.AssetID, + AssetType: params.AssetType, + CreatedBy: params.CreatedBy, + CreatedBefore: params.CreatedBefore, + CreatedAfter: params.CreatedAfter, + OrderBy: params.OrderBy, + PageSize: pageSize, + PageToken: pageToken, + }) + } + return paginate(ctx, params.MaxPages, "assignments", listFn, extractAssignmentsPage) + }) + if err != nil { + return AssignmentsResult{}, err + } + + return AssignmentsResult{ + Meta: page.Meta, + Assignments: page.Items, + TotalSize: page.TotalSize, + PartialError: page.PartialError, + }, nil +} + +// extractTagsPage adapts a tags list envelope for the paginator. +func extractTagsPage(list *components.TagsList) pageData[Tag] { + items := make([]Tag, 0, len(list.Tags)) + for _, t := range list.Tags { + items = append(items, mapTag(t)) + } + + nextPageToken := "" + if npt := list.GetNextPageToken(); npt != nil { + nextPageToken = *npt + } + + return pageData[Tag]{Items: items, TotalSize: list.TotalSize, NextPageToken: nextPageToken} +} + +// extractAssignmentsPage adapts an assignments list envelope for the paginator. +func extractAssignmentsPage(list *components.TagAssignmentsList) pageData[Assignment] { + items := make([]Assignment, 0, len(list.Assignments)) + for _, a := range list.Assignments { + items = append(items, mapTagAssignment(a)) + } + + nextPageToken := "" + if npt := list.GetNextPageToken(); npt != nil { + nextPageToken = *npt + } + + return pageData[Assignment]{Items: items, TotalSize: list.TotalSize, NextPageToken: nextPageToken} +} + +// optionalInt64 narrows an unsigned page size to the signed type the client sends. +func optionalInt64(v mo.Option[uint64]) mo.Option[int64] { + if !v.IsPresent() { + return mo.None[int64]() + } + return mo.Some(int64(v.MustGet())) +} + +// GetTag retrieves a single tag by name or UUID. The endpoint accepts either +// interchangeably, so the raw identifier is passed straight through (no resolve +// roundtrip). A second request counts the tag's assignments, which the tag +// payload itself does not carry. +func (s *tagsService) GetTag( + ctx context.Context, + params GetParams, +) (GetResult, cenclierrors.CencliError) { + orgIDStr := utilconvert.OptionalString(params.OrgID) + + // The endpoint resolves names itself, so this does not go through + // resolveTagID - but it reads a UUID-shaped value as an ID exactly like the + // rest of the API does, so the same name fallback applies. + result, err := s.client.GetTag(ctx, orgIDStr, params.TagID.String()) + if err != nil { + retry := s.resolveNameCollision(ctx, orgIDStr, params.TagID, err) + if retry.IsAbsent() { + return GetResult{}, err + } + result, err = s.client.GetTag(ctx, orgIDStr, retry.MustGet()) + if err != nil { + return GetResult{}, err + } + } + + var meta *responsemeta.ResponseMeta + if result.Metadata.Request != nil || result.Metadata.Response != nil { + meta = responsemeta.NewResponseMeta( + result.Metadata.Request, + result.Metadata.Response, + result.Metadata.Latency, + result.Metadata.Attempts, + ) + } + + var tag Tag + if result.Data != nil { + tag = mapTag(*result.Data) + } + + // An empty ID means the response carried no tag to count against, so there is + // nothing to ask the assignments endpoint about. + var countErr cenclierrors.CencliError + if tag.ID != "" { + tag.AssetCount, countErr = s.assetCount(ctx, orgIDStr, tag.ID) + } + + return GetResult{ + Meta: meta, + Tag: tag, + PartialError: cenclierrors.ToPartialError(countErr), + }, nil +} + +// assetCount reports how many assets a tag is assigned to, reading the total off +// a single-item assignments page. The UUID comes from the tag just fetched, so no +// resolution is needed. A failure is surfaced alongside the tag, not instead of it. +func (s *tagsService) assetCount( + ctx context.Context, + orgID mo.Option[string], + tagID string, +) (*int64, cenclierrors.CencliError) { + progress.ReportMessage(ctx, progress.StageFetch, "Counting assigned assets...") + + result, err := s.client.ListTagAssignments(ctx, client.ListTagAssignmentsRequest{ + OrgID: orgID, + TagID: tagID, + PageSize: mo.Some(int64(1)), + }) + if err != nil { + return nil, err + } + if result.Data == nil { + return nil, nil + } + + count := result.Data.TotalSize + return &count, nil +} + +// CreateTag creates a new tag. The name (non-empty) and privacy are validated +// against the API contract before the request is made. +func (s *tagsService) CreateTag( + ctx context.Context, + params CreateParams, +) (CreateResult, cenclierrors.CencliError) { + if strings.TrimSpace(params.Name) == "" { + return CreateResult{}, NewInvalidTagNameError() + } + if err := validatePrivacy(mo.Some(params.Privacy)); err != nil { + return CreateResult{}, err + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + result, err := s.client.CreateTag(ctx, client.CreateTagRequest{ + OrgID: orgIDStr, + Name: params.Name, + Description: params.Description, + Privacy: params.Privacy, + }) + if err != nil { + return CreateResult{}, err + } + + var meta *responsemeta.ResponseMeta + if result.Metadata.Request != nil || result.Metadata.Response != nil { + meta = responsemeta.NewResponseMeta( + result.Metadata.Request, + result.Metadata.Response, + result.Metadata.Latency, + result.Metadata.Attempts, + ) + } + + var tag Tag + if result.Data != nil { + tag = mapTag(*result.Data) + } + + return CreateResult{Meta: meta, Tag: tag}, nil +} + +// UpdateTag mutates an existing tag by name or UUID. Privacy, when provided, is +// validated; a name is resolved to a UUID (the update endpoint accepts a UUID +// only) before the write. +func (s *tagsService) UpdateTag( + ctx context.Context, + params UpdateParams, +) (UpdateResult, cenclierrors.CencliError) { + if err := validatePrivacy(params.Privacy); err != nil { + return UpdateResult{}, err + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + result, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (client.Result[components.Tag], cenclierrors.CencliError) { + return s.client.UpdateTag(ctx, client.UpdateTagRequest{ + OrgID: orgIDStr, + TagID: tagID, + Name: params.Name, + Description: params.Description, + Privacy: params.Privacy, + }) + }) + if err != nil { + return UpdateResult{}, err + } + + var meta *responsemeta.ResponseMeta + if result.Metadata.Request != nil || result.Metadata.Response != nil { + meta = responsemeta.NewResponseMeta( + result.Metadata.Request, + result.Metadata.Response, + result.Metadata.Latency, + result.Metadata.Attempts, + ) + } + + var tag Tag + if result.Data != nil { + tag = mapTag(*result.Data) + } + + return UpdateResult{Meta: meta, Tag: tag}, nil +} + +// DeleteTag removes a tag by name or UUID. A name is resolved to a UUID (the +// delete endpoint accepts a UUID only) before the deletion. +func (s *tagsService) DeleteTag( + ctx context.Context, + params DeleteParams, +) (DeleteResult, cenclierrors.CencliError) { + orgIDStr := utilconvert.OptionalString(params.OrgID) + + metadata, err := callWithTag(ctx, s, orgIDStr, params.TagID, + func(tagID string) (client.Metadata, cenclierrors.CencliError) { + return s.client.DeleteTag(ctx, orgIDStr, tagID) + }) + if err != nil { + return DeleteResult{}, err + } + + var meta *responsemeta.ResponseMeta + if metadata.Request != nil || metadata.Response != nil { + meta = responsemeta.NewResponseMeta( + metadata.Request, + metadata.Response, + metadata.Latency, + metadata.Attempts, + ) + } + + return DeleteResult{Meta: meta, TagID: params.TagID.String()}, nil +} + +// Assign links a tag (by name or UUID) to explicit assets, one request per +// asset. A failure on one asset does not abort the rest: if every asset fails +// the first error is returned, otherwise the successes come back with a +// PartialError summarizing the failures. +func (s *tagsService) Assign( + ctx context.Context, + params AssignParams, +) (AssignResult, cenclierrors.CencliError) { + if len(params.AssetIDs) == 0 { + return AssignResult{}, NewNoAssetsError() + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + tagID, resolveErr := s.resolveTagID(ctx, orgIDStr, params.TagID) + if resolveErr != nil { + return AssignResult{}, resolveErr + } + + result, err := s.assignEach(ctx, orgIDStr, params, tagID) + if retry := s.retryTagForRun(ctx, orgIDStr, params.TagID, len(result.Assignments), result.Failures, err); retry.IsPresent() { + return s.assignEach(ctx, orgIDStr, params, retry.MustGet()) + } + return result, err +} + +// assignEach runs the per-asset loop against one resolved tag ID. +func (s *tagsService) assignEach( + ctx context.Context, + orgIDStr mo.Option[string], + params AssignParams, + tagID string, +) (AssignResult, cenclierrors.CencliError) { + total := len(params.AssetIDs) + assignments := make([]Assignment, 0, total) + var failures []AssignmentFailure + var firstErr cenclierrors.CencliError + var meta *responsemeta.ResponseMeta + + for i, assetID := range params.AssetIDs { + // Stop early on cancellation, keeping whatever succeeded so far. + if err := ctx.Err(); err != nil { + firstErr = cenclierrors.ParseContextError(err) + break + } + + progress.ReportMessage(ctx, progress.StageProcess, + fmt.Sprintf("Assigning tag (%d/%d)...", i+1, total)) + + result, err := s.client.CreateTagAssignment(ctx, client.CreateTagAssignmentRequest{ + OrgID: orgIDStr, + TagID: tagID, + AssetID: assetID, + }) + if err != nil { + if firstErr == nil { + firstErr = err + } + failures = append(failures, newAssignmentFailure(assetID, err)) + continue + } + + if meta == nil && (result.Metadata.Request != nil || result.Metadata.Response != nil) { + meta = responsemeta.NewResponseMeta( + result.Metadata.Request, + result.Metadata.Response, + result.Metadata.Latency, + result.Metadata.Attempts, + ) + } + if result.Data != nil { + assignments = append(assignments, mapTagAssignment(*result.Data)) + } + } + + partial, fatal := perAssetOutcome(len(assignments), len(failures), firstErr, + func() cenclierrors.CencliError { return newAssignPartialError(len(failures), total) }) + if fatal != nil { + return AssignResult{}, fatal + } + + return AssignResult{ + Meta: meta, + TagID: params.TagID.String(), + Assignments: assignments, + Failures: failures, + PartialError: partial, + }, nil +} + +// Unassign removes a tag (by name or UUID) from explicit assets, looking up each +// asset's assignment before deleting it. Like Assign it is continue-on-error; an +// asset with no assignment is a per-asset failure. +func (s *tagsService) Unassign( + ctx context.Context, + params UnassignParams, +) (UnassignResult, cenclierrors.CencliError) { + if len(params.AssetIDs) == 0 { + return UnassignResult{}, NewNoAssetsError() + } + + orgIDStr := utilconvert.OptionalString(params.OrgID) + + tagID, resolveErr := s.resolveTagID(ctx, orgIDStr, params.TagID) + if resolveErr != nil { + return UnassignResult{}, resolveErr + } + + result, err := s.unassignEach(ctx, orgIDStr, params, tagID) + if retry := s.retryTagForRun(ctx, orgIDStr, params.TagID, len(result.Unassigned), result.Failures, err); retry.IsPresent() { + return s.unassignEach(ctx, orgIDStr, params, retry.MustGet()) + } + return result, err +} + +// unassignEach runs the per-asset lookup+delete loop against one resolved tag ID. +func (s *tagsService) unassignEach( + ctx context.Context, + orgIDStr mo.Option[string], + params UnassignParams, + tagID string, +) (UnassignResult, cenclierrors.CencliError) { + total := len(params.AssetIDs) + unassigned := make([]Assignment, 0, total) + var failures []AssignmentFailure + var firstErr cenclierrors.CencliError + var meta *responsemeta.ResponseMeta + + for i, assetID := range params.AssetIDs { + // Stop early on cancellation, keeping whatever succeeded so far. + if err := ctx.Err(); err != nil { + firstErr = cenclierrors.ParseContextError(err) + break + } + + progress.ReportMessage(ctx, progress.StageProcess, + fmt.Sprintf("Unassigning tag (%d/%d)...", i+1, total)) + + // The delete endpoint is keyed by assignment ID, not asset ID, so look it up. + listResult, err := s.client.ListTagAssignments(ctx, client.ListTagAssignmentsRequest{ + OrgID: orgIDStr, + TagID: tagID, + AssetID: mo.Some(assetID), + PageSize: mo.Some(int64(1)), + }) + if err != nil { + if firstErr == nil { + firstErr = err + } + failures = append(failures, newAssignmentFailure(assetID, err)) + continue + } + if listResult.Data == nil || len(listResult.Data.Assignments) == 0 { + notAssigned := NewAssetNotAssignedError(assetID) + if firstErr == nil { + firstErr = notAssigned + } + failures = append(failures, newAssignmentFailure(assetID, notAssigned)) + continue + } + + assignment := listResult.Data.Assignments[0] + metadata, err := s.client.DeleteTagAssignment(ctx, orgIDStr, tagID, assignment.ID) + if err != nil { + if firstErr == nil { + firstErr = err + } + failures = append(failures, newAssignmentFailure(assetID, err)) + continue + } + + if meta == nil && (metadata.Request != nil || metadata.Response != nil) { + meta = responsemeta.NewResponseMeta( + metadata.Request, + metadata.Response, + metadata.Latency, + metadata.Attempts, + ) + } + unassigned = append(unassigned, mapTagAssignment(assignment)) + } + + partial, fatal := perAssetOutcome(len(unassigned), len(failures), firstErr, + func() cenclierrors.CencliError { return newUnassignPartialError(len(failures), total) }) + if fatal != nil { + return UnassignResult{}, fatal + } + + return UnassignResult{ + Meta: meta, + TagID: params.TagID.String(), + Unassigned: unassigned, + Failures: failures, + PartialError: partial, + }, nil +} + +// resolveTagID returns a concrete tag UUID for the given identifier. A value +// that already parses as a UUID is returned unchanged with no API call; a name +// is resolved to its UUID via an exact-match ListTags lookup. Reused by the +// UUID-only endpoints (update, delete, and later assignments/operations). +func (s *tagsService) resolveTagID( + ctx context.Context, + orgID mo.Option[string], + tagID identifiers.TagID, +) (string, cenclierrors.CencliError) { + // Never look up an empty identifier: an empty name filter matches nothing + // meaningful and would resolve to an arbitrary tag. + if tagID.String() == "" { + return "", NewEmptyTagIDError() + } + + // A UUID needs no resolution — pass it straight through, no lookup. If no tag + // actually has that ID, resolveNameCollision retries it as a name afterwards. + if tagID.UID().IsPresent() { + return tagID.String(), nil + } + + id, err := s.lookupTagIDByName(ctx, orgID, tagID.String()) + if err != nil { + return "", err + } + if id.IsAbsent() { + return "", NewTagNotFoundError(tagID.String()) + } + return id.MustGet(), nil +} + +// lookupTagIDByName finds a tag's UUID by its exact name. An absent result means +// no tag carries that name, which is not on its own an error. +func (s *tagsService) lookupTagIDByName( + ctx context.Context, + orgID mo.Option[string], + name string, +) (mo.Option[string], cenclierrors.CencliError) { + result, err := s.client.ListTags(ctx, client.ListTagsRequest{ + OrgID: orgID, + Name: mo.Some(name), + PageSize: mo.Some(int64(1)), + }) + if err != nil { + return mo.None[string](), err + } + if result.Data == nil || len(result.Data.Tags) == 0 { + return mo.None[string](), nil + } + return mo.Some(result.Data.Tags[0].ID), nil +} + +// resolveNameCollision settles the case resolveTagID cannot: an identifier that +// parses as a UUID but is actually a tag's name. IDs win, so it runs only once +// the API has said nothing carries that ID, keeping the common path at zero +// extra requests. Absent means "keep the original error". +func (s *tagsService) resolveNameCollision( + ctx context.Context, + orgID mo.Option[string], + tagID identifiers.TagID, + cause cenclierrors.CencliError, +) mo.Option[string] { + if !tagID.UID().IsPresent() || !isMissingTagError(cause) { + return mo.None[string]() + } + + // A failure here is not worth surfacing - the caller already has a real error. + id, err := s.lookupTagIDByName(ctx, orgID, tagID.String()) + if err != nil || id.IsAbsent() || id.MustGet() == tagID.String() { + return mo.None[string]() + } + return id +} + +// callWithTag resolves the identifier, runs fn against it, and retries once as a +// tag name if the API says nothing carries that ID. Every single-request tag verb +// uses it, so the two readings are tried in the same order everywhere. +func callWithTag[T any]( + ctx context.Context, + s *tagsService, + orgID mo.Option[string], + tagID identifiers.TagID, + fn func(resolved string) (T, cenclierrors.CencliError), +) (T, cenclierrors.CencliError) { + var zero T + + resolved, err := s.resolveTagID(ctx, orgID, tagID) + if err != nil { + return zero, err + } + + result, err := fn(resolved) + if err == nil { + return result, nil + } + if retry := s.resolveNameCollision(ctx, orgID, tagID, err); retry.IsPresent() { + return fn(retry.MustGet()) + } + return zero, err +} + +// retryTagForRun reports the tag to re-run a per-asset loop against: set only +// when the run placed nothing and every asset failed the way a missing tag does, +// so re-running cannot duplicate work. Shared by Assign and Unassign. +func (s *tagsService) retryTagForRun( + ctx context.Context, + orgID mo.Option[string], + tagID identifiers.TagID, + succeeded int, + failures []AssignmentFailure, + err cenclierrors.CencliError, +) mo.Option[string] { + if err != nil || succeeded > 0 || len(failures) == 0 { + return mo.None[string]() + } + return s.resolveNameCollision(ctx, orgID, tagID, failures[0].Err) +} + +// isMissingTagError reports whether the API said no such tag exists. A 403 counts: +// the API masks existence, so forbidden and missing are indistinguishable. +func isMissingTagError(err cenclierrors.CencliError) bool { + var coded interface{ StatusCode() mo.Option[int64] } + if !errors.As(err, &coded) { + return false + } + status := coded.StatusCode() + return status.IsPresent() && (status.MustGet() == 404 || status.MustGet() == 403) +} + +// mapTag converts an SDK tag into the domain DTO. +func mapTag(t components.Tag) Tag { + return Tag{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + Privacy: string(t.Privacy), + CreatedBy: t.CreatedBy, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +// mapTagAssignment converts an SDK tag assignment into the domain DTO. +func mapTagAssignment(a components.TagAssignment) Assignment { + return Assignment{ + ID: a.ID, + TagID: a.TagID, + AssetID: a.AssetID, + AssetType: string(a.AssetType), + PlatformRef: a.PlatformRef, + CreatedBy: a.CreatedBy, + CreatedAt: a.CreatedAt, + } +} diff --git a/internal/app/tags/service_test.go b/internal/app/tags/service_test.go new file mode 100644 index 0000000..196c34c --- /dev/null +++ b/internal/app/tags/service_test.go @@ -0,0 +1,1969 @@ +package tags + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/sdkerrors" + + "github.com/censys/cencli/gen/client/mocks" + "github.com/censys/cencli/internal/app/progress" + "github.com/censys/cencli/internal/app/streaming" + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" +) + +func strPtr(s string) *string { return &s } + +func okMeta() client.Metadata { + return client.Metadata{ + Request: &http.Request{Method: "GET", URL: &url.URL{Scheme: "https", Host: "api.censys.io"}}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + } +} + +func tagPage(names []string, total int64, nextToken string) client.Result[components.TagsList] { + tags := make([]components.Tag, 0, len(names)) + for _, n := range names { + tags = append(tags, components.Tag{ID: n + "-id", Name: n, Privacy: components.TagPrivacyShared}) + } + list := &components.TagsList{Tags: tags, TotalSize: total} + if nextToken != "" { + list.NextPageToken = strPtr(nextToken) + } + return client.Result[components.TagsList]{Metadata: okMeta(), Data: list} +} + +func TestTagsService_ListTags(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params ListParams + ctx func() context.Context + assert func(t *testing.T, res ListResult, err cenclierrors.CencliError) + }{ + { + name: "success - single page, no filters", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{}). + Return(tagPage([]string{"alpha", "beta"}, 2, ""), nil) + return m + }, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Tags, 2) + require.Equal(t, int64(2), res.TotalSize) + require.Equal(t, "alpha", res.Tags[0].Name) + require.Equal(t, "shared", res.Tags[0].Privacy) + require.NotNil(t, res.Meta) + }, + }, + { + name: "success - filters and org threaded to client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + OrgID: mo.Some("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + PageSize: mo.Some(int64(50)), + OrderBy: mo.Some("name_desc"), + Name: mo.Some("my-tag"), + CreatedBy: mo.Some("creator-id"), + Privacy: mo.Some("shared"), + }).Return(tagPage([]string{"my-tag"}, 1, ""), nil) + return m + }, + params: ListParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"))), + Privacy: mo.Some("shared"), + Name: mo.Some("my-tag"), + CreatedBy: mo.Some("creator-id"), + OrderBy: mo.Some("name_desc"), + PageSize: mo.Some(uint64(50)), + }, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Tags, 1) + require.Equal(t, "my-tag", res.Tags[0].Name) + }, + }, + { + name: "pagination - multiple pages collected", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2))}). + Return(tagPage([]string{"a", "b"}, 5, "token1"), nil), + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2)), PageToken: mo.Some("token1")}). + Return(tagPage([]string{"c", "d"}, 5, "token2"), nil), + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2)), PageToken: mo.Some("token2")}). + Return(tagPage([]string{"e"}, 5, ""), nil), + ) + return m + }, + params: ListParams{PageSize: mo.Some(uint64(2))}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Tags, 5) + require.Equal(t, int64(5), res.TotalSize) + }, + }, + { + name: "pagination - limited by max-pages", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2))}). + Return(tagPage([]string{"a", "b"}, 10, "token1"), nil), + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2)), PageToken: mo.Some("token1")}). + Return(tagPage([]string{"c", "d"}, 10, "token2"), nil), + // third page must NOT be called + ) + return m + }, + params: ListParams{PageSize: mo.Some(uint64(2)), MaxPages: mo.Some(uint64(2))}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Tags, 4) + }, + }, + { + name: "first-page error returned immediately", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + detail := "Invalid request" + status := int64(400) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{Detail: &detail, Status: &status}) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{}, structuredErr) + return m + }, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Nil(t, res.Tags) + require.Contains(t, err.Error(), "Invalid request") + }, + }, + { + name: "later-page error yields partial result", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2))}). + Return(tagPage([]string{"a", "b"}, 5, "token1"), nil), + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2)), PageToken: mo.Some("token1")}). + Return(client.Result[components.TagsList]{}, client.NewClientError(errors.New("network error"))), + ) + return m + }, + params: ListParams{PageSize: mo.Some(uint64(2))}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Tags, 2) + require.NotNil(t, res.PartialError) + require.Contains(t, res.PartialError.Error(), "network error") + }, + }, + { + name: "empty result", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(tagPage(nil, 0, ""), nil) + return m + }, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Empty(t, res.Tags) + require.Equal(t, int64(0), res.TotalSize) + }, + }, + { + name: "invalid page size", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: ListParams{PageSize: mo.Some(uint64(0))}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "page size") + }, + }, + { + name: "invalid max pages", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: ListParams{MaxPages: mo.Some(uint64(0))}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "max pages") + }, + }, + { + name: "invalid order-by rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: ListParams{OrderBy: mo.Some("bogus")}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "order-by") + require.Contains(t, err.Error(), "name_asc") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "invalid privacy rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: ListParams{Privacy: mo.Some("bogus")}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "privacy") + require.Contains(t, err.Error(), "private") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "valid order-by and privacy pass through", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + OrderBy: mo.Some("created_at_desc"), + Privacy: mo.Some("private"), + }).Return(tagPage([]string{"a"}, 1, ""), nil) + return m + }, + params: ListParams{OrderBy: mo.Some("created_at_desc"), Privacy: mo.Some("private")}, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Tags, 1) + }, + }, + { + name: "context cancellation propagates", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + assert: func(t *testing.T, res ListResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.ListTags(ctx, tc.params) + tc.assert(t, res, err) + }) + } +} + +// TestTagsService_ListTags_Progress verifies the running collected count is +// reported across pages. +func TestTagsService_ListTags_Progress(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2))}). + Return(tagPage([]string{"a", "b"}, 5, "token1"), nil), + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2)), PageToken: mo.Some("token1")}). + Return(tagPage([]string{"c", "d"}, 5, "token2"), nil), + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{PageSize: mo.Some(int64(2)), PageToken: mo.Some("token2")}). + Return(tagPage([]string{"e"}, 5, ""), nil), + ) + + pub, events := progress.NewChannelPublisher(64) + ctx := progress.WithPublisher(context.Background(), pub) + + svc := New(m) + res, err := svc.ListTags(ctx, ListParams{PageSize: mo.Some(uint64(2))}) + require.NoError(t, err) + require.Len(t, res.Tags, 5) + + pub.Close(nil) + var msgs []string + for ev := range events { + if ev.Message != "" { + msgs = append(msgs, ev.Message) + } + } + // Progress is reported from the second page onward; the collected count must + // be non-zero and increasing (never stuck at 0 as in the pre-TAGS-1 bug). + require.NotEmpty(t, msgs) + require.Contains(t, msgs[len(msgs)-1], "collected") +} + +func TestTagsService_GetTag(t *testing.T) { + orgUUID := uuid.New() + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params GetParams + assert func(t *testing.T, res GetResult, err cenclierrors.CencliError) + }{ + { + name: "success by name - maps SDK tag to DTO", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + desc := "a description" + sdkTag := &components.Tag{ + ID: "tag-id", + Name: "my-tag", + Description: &desc, + Privacy: components.TagPrivacyPrivate, + CreatedBy: "creator", + } + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), "my-tag"). + Return(client.Result[components.Tag]{Metadata: okMeta(), Data: sdkTag}, nil) + // Every successful get also counts assignments; this case is about + // the mapping, so the count itself is left unasserted. + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(assignmentsPage(nil, 0, ""), nil) + return m + }, + params: GetParams{TagID: identifiers.NewTagID("my-tag")}, + assert: func(t *testing.T, res GetResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "tag-id", res.Tag.ID) + require.Equal(t, "my-tag", res.Tag.Name) + require.Equal(t, "private", res.Tag.Privacy) + require.NotNil(t, res.Tag.Description) + require.Equal(t, "a description", *res.Tag.Description) + require.Equal(t, "creator", res.Tag.CreatedBy) + require.NotNil(t, res.Meta) + }, + }, + { + name: "org id threaded to client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTag(gomock.Any(), mo.Some(orgUUID.String()), orgUUID.String()). + Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: "id", Name: "n", Privacy: components.TagPrivacyShared}}, nil) + // The org ID must reach the count request too, not just the get. + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + OrgID: mo.Some(orgUUID.String()), + TagID: "id", + PageSize: mo.Some(int64(1)), + }).Return(assignmentsPage(nil, 0, ""), nil) + return m + }, + params: GetParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + TagID: identifiers.NewTagID(orgUUID.String()), + }, + assert: func(t *testing.T, res GetResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "n", res.Tag.Name) + }, + }, + { + name: "client error propagates", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + detail := "Tag not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{Detail: &detail, Status: &status}) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), "missing"). + Return(client.Result[components.Tag]{}, structuredErr) + return m + }, + params: GetParams{TagID: identifiers.NewTagID("missing")}, + assert: func(t *testing.T, res GetResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "Tag not found") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.GetTag(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +func TestTagsService_CreateTag(t *testing.T) { + orgUUID := uuid.New() + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params CreateParams + assert func(t *testing.T, res CreateResult, err cenclierrors.CencliError) + }{ + { + name: "success - maps SDK tag to DTO", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + desc := "a description" + sdkTag := &components.Tag{ + ID: "tag-id", + Name: "my-tag", + Description: &desc, + Privacy: components.TagPrivacyPrivate, + CreatedBy: "creator", + } + m.EXPECT().CreateTag(gomock.Any(), client.CreateTagRequest{ + Name: "my-tag", + Description: mo.Some("a description"), + Privacy: "private", + }).Return(client.Result[components.Tag]{Metadata: okMeta(), Data: sdkTag}, nil) + return m + }, + params: CreateParams{ + Name: "my-tag", + Description: mo.Some("a description"), + Privacy: "private", + }, + assert: func(t *testing.T, res CreateResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "tag-id", res.Tag.ID) + require.Equal(t, "my-tag", res.Tag.Name) + require.Equal(t, "private", res.Tag.Privacy) + require.NotNil(t, res.Tag.Description) + require.Equal(t, "a description", *res.Tag.Description) + require.Equal(t, "creator", res.Tag.CreatedBy) + require.NotNil(t, res.Meta) + }, + }, + { + name: "org id and shared privacy threaded to client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CreateTag(gomock.Any(), client.CreateTagRequest{ + OrgID: mo.Some(orgUUID.String()), + Name: "shared-tag", + Privacy: "shared", + }).Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: "id", Name: "shared-tag", Privacy: components.TagPrivacyShared}}, nil) + return m + }, + params: CreateParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + Name: "shared-tag", + Privacy: "shared", + }, + assert: func(t *testing.T, res CreateResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "shared-tag", res.Tag.Name) + require.Equal(t, "shared", res.Tag.Privacy) + }, + }, + { + name: "empty name rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: CreateParams{Name: "", Privacy: "private"}, + assert: func(t *testing.T, res CreateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "name") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "whitespace-only name rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: CreateParams{Name: " ", Privacy: "private"}, + assert: func(t *testing.T, res CreateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "name") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "invalid privacy rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: CreateParams{Name: "my-tag", Privacy: "bogus"}, + assert: func(t *testing.T, res CreateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "privacy") + require.Contains(t, err.Error(), "private") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "client error propagates", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + detail := "Tag already exists" + status := int64(409) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{Detail: &detail, Status: &status}) + m.EXPECT().CreateTag(gomock.Any(), client.CreateTagRequest{Name: "dupe", Privacy: "private"}). + Return(client.Result[components.Tag]{}, structuredErr) + return m + }, + params: CreateParams{Name: "dupe", Privacy: "private"}, + assert: func(t *testing.T, res CreateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "Tag already exists") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.CreateTag(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +func TestTagsService_UpdateTag(t *testing.T) { + orgUUID := uuid.New() + tagUUID := uuid.New() + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params UpdateParams + assert func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) + }{ + { + name: "success - UUID input skips resolution and maps SDK tag to DTO", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + // A UUID needs no name resolution. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + desc := "new description" + sdkTag := &components.Tag{ + ID: "tag-id", + Name: "renamed", + Description: &desc, + Privacy: components.TagPrivacyShared, + CreatedBy: "creator", + } + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{ + TagID: tagUUID.String(), + Name: mo.Some("renamed"), + Description: mo.Some("new description"), + Privacy: mo.Some("shared"), + }).Return(client.Result[components.Tag]{Metadata: okMeta(), Data: sdkTag}, nil) + return m + }, + params: UpdateParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + Name: mo.Some("renamed"), + Description: mo.Some("new description"), + Privacy: mo.Some("shared"), + }, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "tag-id", res.Tag.ID) + require.Equal(t, "renamed", res.Tag.Name) + require.Equal(t, "shared", res.Tag.Privacy) + require.NotNil(t, res.Tag.Description) + require.Equal(t, "new description", *res.Tag.Description) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name resolved to UUID before the write", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), + PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{Data: &components.TagsList{ + Tags: []components.Tag{{ID: "resolved-id", Name: "my-tag"}}, + }}, nil) + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{ + TagID: "resolved-id", + Privacy: mo.Some("shared"), + }).Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: "resolved-id", Name: "my-tag", Privacy: components.TagPrivacyShared}}, nil) + return m + }, + params: UpdateParams{TagID: identifiers.NewTagID("my-tag"), Privacy: mo.Some("shared")}, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "resolved-id", res.Tag.ID) + }, + }, + { + name: "name not found returns typed error, no write", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{Data: &components.TagsList{}}, nil) + return m + }, + params: UpdateParams{TagID: identifiers.NewTagID("ghost"), Privacy: mo.Some("shared")}, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "not found") + require.False(t, err.ShouldPrintUsage()) + }, + }, + { + name: "partial update - only privacy, org and raw UUID threaded to client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{ + OrgID: mo.Some(orgUUID.String()), + TagID: tagUUID.String(), + Privacy: mo.Some("private"), + }).Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: "id", Name: "n", Privacy: components.TagPrivacyPrivate}}, nil) + return m + }, + params: UpdateParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + TagID: identifiers.NewTagID(tagUUID.String()), + Privacy: mo.Some("private"), + }, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "private", res.Tag.Privacy) + }, + }, + { + name: "clear description sent as empty string", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{ + TagID: tagUUID.String(), + Description: mo.Some(""), + }).Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: "id", Name: "my-tag", Privacy: components.TagPrivacyPrivate}}, nil) + return m + }, + params: UpdateParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + Description: mo.Some(""), + }, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, "id", res.Tag.ID) + }, + }, + { + name: "invalid privacy rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) // no client call expected + }, + params: UpdateParams{TagID: identifiers.NewTagID("my-tag"), Privacy: mo.Some("bogus")}, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "privacy") + require.Contains(t, err.Error(), "private") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "client error propagates", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + detail := "Tag not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{Detail: &detail, Status: &status}) + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{TagID: tagUUID.String(), Name: mo.Some("x")}). + Return(client.Result[components.Tag]{}, structuredErr) + // A missing tag ID is also how a UUID-shaped *name* looks, so one + // lookup asks whether any tag carries that name. Nothing does here, + // so the API's original error is what the caller sees. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{Data: &components.TagsList{}}, nil) + return m + }, + params: UpdateParams{TagID: identifiers.NewTagID(tagUUID.String()), Name: mo.Some("x")}, + assert: func(t *testing.T, res UpdateResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Tag.ID) + require.Contains(t, err.Error(), "Tag not found") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.UpdateTag(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +func assignmentResult(id, assetID string) client.Result[components.TagAssignment] { + return client.Result[components.TagAssignment]{ + Metadata: okMeta(), + Data: &components.TagAssignment{ + ID: id, + TagID: "tag-id", + AssetID: assetID, + AssetType: components.TagAssignmentAssetTypeHost, + PlatformRef: "https://platform.censys.io/hosts/" + assetID, + }, + } +} + +// expectNoTagOfThatName satisfies the name fallback with an empty lookup. Any +// test whose UUID-shaped tag gets a missing-tag response needs it, since the +// service then asks whether a tag is *named* that UUID before giving up. +func expectNoTagOfThatName(m *mocks.MockClient) { + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{Data: &components.TagsList{}}, nil) +} + +func clientStructuredError(detail string, status int64) client.ClientError { + return client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{Detail: &detail, Status: &status}) +} + +// assignmentsListResult builds a single-assignment list response (what an +// asset_id-filtered ListTagAssignments returns for an assigned asset). +func assignmentsListResult(id, assetID string) client.Result[components.TagAssignmentsList] { + return client.Result[components.TagAssignmentsList]{ + Metadata: okMeta(), + Data: &components.TagAssignmentsList{ + Assignments: []components.TagAssignment{{ + ID: id, + TagID: "tag-id", + AssetID: assetID, + AssetType: components.TagAssignmentAssetTypeHost, + PlatformRef: "https://platform.censys.io/hosts/" + assetID, + }}, + TotalSize: 1, + }, + } +} + +// emptyAssignmentsListResult is the response for an asset with no assignment to +// the tag (nothing to unassign). +func emptyAssignmentsListResult() client.Result[components.TagAssignmentsList] { + return client.Result[components.TagAssignmentsList]{ + Metadata: okMeta(), + Data: &components.TagAssignmentsList{TotalSize: 0}, + } +} + +func TestTagsService_Assign(t *testing.T) { + orgUUID := uuid.New() + tagUUID := uuid.New() + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params AssignParams + assert func(t *testing.T, res AssignResult, err cenclierrors.CencliError) + }{ + { + name: "all assets assigned; UUID input skips resolution", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{ + TagID: tagUUID.String(), AssetID: "8.8.8.8", + }).Return(assignmentResult("a1", "8.8.8.8"), nil) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{ + TagID: tagUUID.String(), AssetID: "1.1.1.1", + }).Return(assignmentResult("a2", "1.1.1.1"), nil) + return m + }, + params: AssignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 2) + require.Empty(t, res.Failures) + require.Nil(t, res.PartialError) + require.Equal(t, tagUUID.String(), res.TagID) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name resolved to UUID once, then assets assigned", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), + PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{Data: &components.TagsList{ + Tags: []components.Tag{{ID: "resolved-id", Name: "my-tag"}}, + }}, nil) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{ + TagID: "resolved-id", AssetID: "8.8.8.8", + }).Return(assignmentResult("a1", "8.8.8.8"), nil) + return m + }, + params: AssignParams{ + TagID: identifiers.NewTagID("my-tag"), + AssetIDs: []string{"8.8.8.8"}, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + require.Equal(t, "my-tag", res.TagID) + }, + }, + { + name: "partial failure: one asset fails, the rest still assigned", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{ + TagID: tagUUID.String(), AssetID: "8.8.8.8", + }).Return(assignmentResult("a1", "8.8.8.8"), nil) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{ + TagID: tagUUID.String(), AssetID: "1.1.1.1", + }).Return(client.Result[components.TagAssignment]{}, clientStructuredError("Forbidden", 403)) + return m + }, + params: AssignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + require.Len(t, res.Failures, 1) + require.Equal(t, "1.1.1.1", res.Failures[0].AssetID) + require.NotNil(t, res.PartialError) + require.Contains(t, res.PartialError.Error(), "1 of 2") + }, + }, + { + // Every asset failing must still report every asset. Collapsing the + // run to one error would hide which assets failed and why, and would + // leave -O json with nothing to emit at all. + name: "all assets fail: every failure is kept", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CreateTagAssignment(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagAssignment]{}, clientStructuredError("Permission denied", 403)). + Times(2) + expectNoTagOfThatName(m) + return m + }, + params: AssignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + // Not a hard error: the command renders the outcomes and owns the + // exit code, the way it does for a terminal operation status. + require.NoError(t, err) + require.Empty(t, res.Assignments) + require.Len(t, res.Failures, 2) + require.Equal(t, "8.8.8.8", res.Failures[0].AssetID) + require.Equal(t, "1.1.1.1", res.Failures[1].AssetID) + require.Contains(t, res.Failures[0].Err.Error(), "Permission denied") + // Detail is the API's one-line summary, not the whole problem + // document Err renders - a report of many assets cannot spend a + // dozen lines on each one. + require.Equal(t, "Permission denied", res.Failures[0].Detail) + require.Equal(t, mo.Some(int64(403)), res.Failures[0].Status) + // Nothing partially succeeded, so a partial error would be a lie. + require.NoError(t, res.PartialError) + }, + }, + { + name: "empty identifier rejected before any lookup or assignment", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().CreateTagAssignment(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: AssignParams{ + TagID: identifiers.NewTagID(" "), + AssetIDs: []string{"8.8.8.8"}, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "no assets rejected before any call", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().CreateTagAssignment(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: AssignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: nil, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "org id threaded through to the client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{ + OrgID: mo.Some(orgUUID.String()), TagID: tagUUID.String(), AssetID: "8.8.8.8", + }).Return(assignmentResult("a1", "8.8.8.8"), nil) + return m + }, + params: AssignParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8"}, + }, + assert: func(t *testing.T, res AssignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.Assign(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +// A cancellation mid-run, after some assets have already been assigned, must be +// surfaced as a PartialError rather than reported as a clean success. +func TestTagsService_Assign_CancellationSurfacesPartial(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tagUUID := uuid.New() + ctx, cancel := context.WithCancel(context.Background()) + + m := mocks.NewMockClient(ctrl) + // First asset succeeds and cancels the context; the loop then stops before + // the second asset, with one success and no recorded failure. + m.EXPECT().CreateTagAssignment(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ client.CreateTagAssignmentRequest) (client.Result[components.TagAssignment], client.ClientError) { + cancel() + return assignmentResult("a1", "8.8.8.8"), nil + }) + + svc := New(m) + res, err := svc.Assign(ctx, AssignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }) + + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + require.Empty(t, res.Failures) + require.NotNil(t, res.PartialError) +} + +func TestTagsService_Unassign(t *testing.T) { + orgUUID := uuid.New() + tagUUID := uuid.New() + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params UnassignParams + assert func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) + }{ + { + name: "all assets unassigned; UUID input skips resolution", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), AssetID: mo.Some("8.8.8.8"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a1", "8.8.8.8"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), tagUUID.String(), "a1"). + Return(okMeta(), nil) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), AssetID: mo.Some("1.1.1.1"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a2", "1.1.1.1"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), tagUUID.String(), "a2"). + Return(okMeta(), nil) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Unassigned, 2) + require.Empty(t, res.Failures) + require.Nil(t, res.PartialError) + require.Equal(t, tagUUID.String(), res.TagID) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name resolved to UUID once, then assets unassigned", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), + PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{Data: &components.TagsList{ + Tags: []components.Tag{{ID: "resolved-id", Name: "my-tag"}}, + }}, nil) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: "resolved-id", AssetID: mo.Some("8.8.8.8"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a1", "8.8.8.8"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), "resolved-id", "a1"). + Return(okMeta(), nil) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID("my-tag"), + AssetIDs: []string{"8.8.8.8"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Unassigned, 1) + require.Equal(t, "my-tag", res.TagID) + }, + }, + { + name: "not assigned: asset with no assignment is a per-asset failure", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), AssetID: mo.Some("8.8.8.8"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a1", "8.8.8.8"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), tagUUID.String(), "a1"). + Return(okMeta(), nil) + // The second asset has no assignment; its removal is never attempted. + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), AssetID: mo.Some("1.1.1.1"), PageSize: mo.Some(int64(1)), + }).Return(emptyAssignmentsListResult(), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), gomock.Any(), "a2").Times(0) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Unassigned, 1) + require.Len(t, res.Failures, 1) + require.Equal(t, "1.1.1.1", res.Failures[0].AssetID) + require.Contains(t, res.Failures[0].Err.Error(), "not assigned") + require.NotNil(t, res.PartialError) + require.Contains(t, res.PartialError.Error(), "1 of 2") + }, + }, + { + name: "partial failure: one asset's removal fails, the rest still unassigned", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), AssetID: mo.Some("8.8.8.8"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a1", "8.8.8.8"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), tagUUID.String(), "a1"). + Return(okMeta(), nil) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), AssetID: mo.Some("1.1.1.1"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a2", "1.1.1.1"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), tagUUID.String(), "a2"). + Return(client.Metadata{}, clientStructuredError("Forbidden", 403)) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Unassigned, 1) + require.Len(t, res.Failures, 1) + require.Equal(t, "1.1.1.1", res.Failures[0].AssetID) + require.NotNil(t, res.PartialError) + require.Contains(t, res.PartialError.Error(), "1 of 2") + }, + }, + { + // The common shape of this: a typo'd list where nothing is assigned. + // Naming only the first asset would leave the rest unaccounted for. + name: "all assets fail: every failure is kept", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(emptyAssignmentsListResult(), nil).Times(2) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Empty(t, res.Unassigned) + require.Len(t, res.Failures, 2) + require.Equal(t, "8.8.8.8", res.Failures[0].AssetID) + require.Equal(t, "1.1.1.1", res.Failures[1].AssetID) + require.Contains(t, res.Failures[1].Err.Error(), "not assigned") + // Our own typed errors carry no API detail or status, so Detail + // falls back to the message and Status stays absent. + require.Contains(t, res.Failures[1].Detail, "not assigned") + require.True(t, res.Failures[1].Status.IsAbsent()) + require.NoError(t, res.PartialError) + }, + }, + { + name: "empty identifier rejected before any lookup or removal", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID(" "), + AssetIDs: []string{"8.8.8.8"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "no assets rejected before any call", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: UnassignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: nil, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "org id threaded through to the client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + OrgID: mo.Some(orgUUID.String()), TagID: tagUUID.String(), + AssetID: mo.Some("8.8.8.8"), PageSize: mo.Some(int64(1)), + }).Return(assignmentsListResult("a1", "8.8.8.8"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), mo.Some(orgUUID.String()), tagUUID.String(), "a1"). + Return(okMeta(), nil) + return m + }, + params: UnassignParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8"}, + }, + assert: func(t *testing.T, res UnassignResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Unassigned, 1) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.Unassign(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +// A cancellation mid-run, after some assets have already been unassigned, must be +// surfaced as a PartialError rather than reported as a clean success. +func TestTagsService_Unassign_CancellationSurfacesPartial(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tagUUID := uuid.New() + ctx, cancel := context.WithCancel(context.Background()) + + m := mocks.NewMockClient(ctrl) + // First asset is looked up and removed; the delete cancels the context, so the + // loop stops before the second asset with one success and no recorded failure. + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(assignmentsListResult("a1", "8.8.8.8"), nil) + m.EXPECT().DeleteTagAssignment(gomock.Any(), gomock.Any(), gomock.Any(), "a1"). + DoAndReturn(func(_ context.Context, _ mo.Option[string], _, _ string) (client.Metadata, client.ClientError) { + cancel() + return okMeta(), nil + }) + + svc := New(m) + res, err := svc.Unassign(ctx, UnassignParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }) + + require.NoError(t, err) + require.Len(t, res.Unassigned, 1) + require.Empty(t, res.Failures) + require.NotNil(t, res.PartialError) +} + +func TestTagsService_DeleteTag(t *testing.T) { + orgUUID := uuid.New() + tagUUID := uuid.New() + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params DeleteParams + assert func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) + }{ + { + name: "success - UUID input skips resolution, returns metadata and echoes the identifier", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + // A UUID needs no name resolution. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().DeleteTag(gomock.Any(), mo.None[string](), tagUUID.String()). + Return(okMeta(), nil) + return m + }, + params: DeleteParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, tagUUID.String(), res.TagID) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name resolved to UUID before deletion; original identifier echoed", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), + PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{Data: &components.TagsList{ + Tags: []components.Tag{{ID: "resolved-id", Name: "my-tag"}}, + }}, nil) + m.EXPECT().DeleteTag(gomock.Any(), mo.None[string](), "resolved-id"). + Return(okMeta(), nil) + return m + }, + params: DeleteParams{TagID: identifiers.NewTagID("my-tag")}, + assert: func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) { + require.NoError(t, err) + // The render shows what the user typed, not the resolved UUID. + require.Equal(t, "my-tag", res.TagID) + }, + }, + { + name: "name not found returns typed error, no deletion", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{Data: &components.TagsList{}}, nil) + return m + }, + params: DeleteParams{TagID: identifiers.NewTagID("ghost")}, + assert: func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.TagID) + require.Contains(t, err.Error(), "not found") + require.False(t, err.ShouldPrintUsage()) + }, + }, + { + name: "empty identifier rejected before any lookup or deletion", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + // Neither a resolve lookup nor a delete may fire for an empty id. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().DeleteTag(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: DeleteParams{TagID: identifiers.NewTagID(" ")}, + assert: func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.TagID) + require.Contains(t, err.Error(), "required") + require.True(t, err.ShouldPrintUsage()) + }, + }, + { + name: "org id threaded; raw identifier passed straight through", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().DeleteTag(gomock.Any(), mo.Some(orgUUID.String()), tagUUID.String()). + Return(okMeta(), nil) + return m + }, + params: DeleteParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + TagID: identifiers.NewTagID(tagUUID.String()), + }, + assert: func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Equal(t, tagUUID.String(), res.TagID) + }, + }, + { + name: "client error propagates", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + detail := "Tag not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{Detail: &detail, Status: &status}) + m.EXPECT().DeleteTag(gomock.Any(), mo.None[string](), tagUUID.String()). + Return(client.Metadata{}, structuredErr) + expectNoTagOfThatName(m) + return m + }, + params: DeleteParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, res DeleteResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.TagID) + require.Contains(t, err.Error(), "Tag not found") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + res, err := svc.DeleteTag(context.Background(), tc.params) + tc.assert(t, res, err) + }) + } +} + +// assignmentsPage builds a multi-assignment page, optionally with a next-page token. +func assignmentsPage(assetIDs []string, total int64, nextToken string) client.Result[components.TagAssignmentsList] { + assignments := make([]components.TagAssignment, 0, len(assetIDs)) + for i, a := range assetIDs { + assignments = append(assignments, components.TagAssignment{ + ID: fmt.Sprintf("assignment-%d", i), + TagID: "tag-id", + AssetID: a, + AssetType: components.TagAssignmentAssetTypeHost, + PlatformRef: "https://platform.censys.io/hosts/" + a, + }) + } + list := &components.TagAssignmentsList{Assignments: assignments, TotalSize: total} + if nextToken != "" { + list.NextPageToken = strPtr(nextToken) + } + return client.Result[components.TagAssignmentsList]{Metadata: okMeta(), Data: list} +} + +func TestTagsService_ListAssignments(t *testing.T) { + orgUUID := uuid.New() + tagUUID := uuid.New() + before := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + after := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + params AssignmentsParams + ctx func() context.Context + assert func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) + }{ + { + name: "success - UUID input skips resolution and maps assignments", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), + }).Return(assignmentsPage([]string{"8.8.8.8", "1.1.1.1"}, 2, ""), nil) + return m + }, + params: AssignmentsParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 2) + require.Equal(t, int64(2), res.TotalSize) + require.Equal(t, "8.8.8.8", res.Assignments[0].AssetID) + require.Equal(t, "host", res.Assignments[0].AssetType) + require.Equal(t, "https://platform.censys.io/hosts/8.8.8.8", res.Assignments[0].PlatformRef) + require.NotNil(t, res.Meta) + }, + }, + { + name: "name resolved to UUID before listing", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some("my-tag"), + PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{Data: &components.TagsList{ + Tags: []components.Tag{{ID: "resolved-id", Name: "my-tag"}}, + }}, nil) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: "resolved-id", + }).Return(assignmentsPage([]string{"8.8.8.8"}, 1, ""), nil) + return m + }, + params: AssignmentsParams{TagID: identifiers.NewTagID("my-tag")}, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + }, + }, + { + name: "filters and org threaded to client", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + OrgID: mo.Some(orgUUID.String()), + TagID: tagUUID.String(), + AssetID: mo.Some("8.8.8.8"), + AssetType: mo.Some("host"), + CreatedBy: mo.Some("creator-id"), + CreatedBefore: mo.Some(before), + CreatedAfter: mo.Some(after), + OrderBy: mo.Some("create_time_asc"), + PageSize: mo.Some(int64(50)), + }).Return(assignmentsPage([]string{"8.8.8.8"}, 1, ""), nil) + return m + }, + params: AssignmentsParams{ + OrgID: mo.Some(identifiers.NewOrganizationID(orgUUID)), + TagID: identifiers.NewTagID(tagUUID.String()), + AssetID: mo.Some("8.8.8.8"), + AssetType: mo.Some("host"), + CreatedBy: mo.Some("creator-id"), + CreatedBefore: mo.Some(before), + CreatedAfter: mo.Some(after), + OrderBy: mo.Some("create_time_asc"), + PageSize: mo.Some(uint64(50)), + }, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + }, + }, + { + name: "pagination - multiple pages collected", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageSize: mo.Some(int64(2)), + }).Return(assignmentsPage([]string{"8.8.8.8", "1.1.1.1"}, 3, "token1"), nil), + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageSize: mo.Some(int64(2)), PageToken: mo.Some("token1"), + }).Return(assignmentsPage([]string{"9.9.9.9"}, 3, ""), nil), + ) + return m + }, + params: AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + PageSize: mo.Some(uint64(2)), + }, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 3) + require.Equal(t, int64(3), res.TotalSize) + }, + }, + { + name: "pagination - limited by max-pages", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageSize: mo.Some(int64(2)), + }).Return(assignmentsPage([]string{"8.8.8.8", "1.1.1.1"}, 10, "token1"), nil) + // the second page must NOT be fetched + return m + }, + params: AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + PageSize: mo.Some(uint64(2)), + MaxPages: mo.Some(uint64(1)), + }, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 2) + require.Equal(t, int64(10), res.TotalSize) + }, + }, + { + name: "pagination stops when the server repeats a page token", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), + }).Return(assignmentsPage([]string{"8.8.8.8"}, 2, "stuck"), nil), + // The server echoes back the same token; the loop must not continue. + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageToken: mo.Some("stuck"), + }).Return(assignmentsPage([]string{"1.1.1.1"}, 2, "stuck"), nil), + ) + return m + }, + params: AssignmentsParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 2) + }, + }, + { + name: "first page error returns hard", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagAssignmentsList]{}, clientStructuredError("Permission denied", 403)) + expectNoTagOfThatName(m) + return m + }, + params: AssignmentsParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Empty(t, res.Assignments) + require.Contains(t, err.Error(), "Permission denied") + }, + }, + { + name: "later page error returns partial results", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), + }).Return(assignmentsPage([]string{"8.8.8.8"}, 5, "token1"), nil), + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageToken: mo.Some("token1"), + }).Return(client.Result[components.TagAssignmentsList]{}, clientStructuredError("Server error", 500)), + ) + return m + }, + params: AssignmentsParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, res AssignmentsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.Len(t, res.Assignments, 1) + require.Error(t, res.PartialError) + require.Contains(t, res.PartialError.Error(), "Server error") + }, + }, + { + name: "invalid order-by rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) + }, + params: AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + OrderBy: mo.Some("name_asc"), + }, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "create_time_asc") + }, + }, + { + name: "invalid asset-type rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) + }, + params: AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + AssetType: mo.Some("hosts"), + }, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "web_property") + }, + }, + { + name: "impossible time window rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) + }, + params: AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + CreatedBefore: mo.Some(after), + CreatedAfter: mo.Some(before), + }, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "created-before must be after created-after") + }, + }, + { + name: "zero page size rejected before any request", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) + }, + params: AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + PageSize: mo.Some(uint64(0)), + }, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "page size") + }, + }, + { + name: "empty identifier rejected before any lookup", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) + }, + params: AssignmentsParams{TagID: identifiers.NewTagID(" ")}, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag name or ID is required") + }, + }, + { + name: "unresolvable name returns tag-not-found", + client: func(ctrl *gomock.Controller) client.Client { + m := mocks.NewMockClient(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{Data: &components.TagsList{}}, nil) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()).Times(0) + return m + }, + params: AssignmentsParams{TagID: identifiers.NewTagID("missing")}, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Contains(t, err.Error(), `tag "missing" not found`) + }, + }, + { + name: "context cancellation propagates", + client: func(ctrl *gomock.Controller) client.Client { + return mocks.NewMockClient(ctrl) + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + params: AssignmentsParams{TagID: identifiers.NewTagID(tagUUID.String())}, + assert: func(t *testing.T, _ AssignmentsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + svc := New(tc.client(ctrl)) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.ListAssignments(ctx, tc.params) + tc.assert(t, res, err) + }) + } +} + +// TestTagsService_ListAssignments_Streaming verifies assignments are emitted +// instead of collected when a streaming emitter is attached. +func TestTagsService_ListAssignments_Streaming(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tagUUID := uuid.New() + m := mocks.NewMockClient(ctrl) + gomock.InOrder( + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageSize: mo.Some(int64(2)), + }).Return(assignmentsPage([]string{"8.8.8.8", "1.1.1.1"}, 3, "token1"), nil), + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), PageSize: mo.Some(int64(2)), PageToken: mo.Some("token1"), + }).Return(assignmentsPage([]string{"9.9.9.9"}, 3, ""), nil), + ) + + emitter, items := streaming.NewChannelEmitter(8) + ctx := streaming.WithEmitter(context.Background(), emitter) + + res, err := New(m).ListAssignments(ctx, AssignmentsParams{ + TagID: identifiers.NewTagID(tagUUID.String()), + PageSize: mo.Some(uint64(2)), + }) + require.NoError(t, err) + require.Empty(t, res.Assignments, "streamed assignments must not also be collected") + require.Equal(t, int64(3), res.TotalSize) + + emitter.Close(nil) + var streamed []string + for item := range items { + if item.Done { + break + } + assignment, ok := item.Data.(Assignment) + require.True(t, ok) + streamed = append(streamed, assignment.AssetID) + } + require.Equal(t, []string{"8.8.8.8", "1.1.1.1", "9.9.9.9"}, streamed) +} + +// TestTagsService_UUIDShapedName covers a tag whose *name* is a UUID. A UUID is +// read as an ID first, so the API finds nothing; the verbs then retry against +// the tag carrying that name. Without this, such a tag is reachable only by its +// real ID and cannot be managed by the name its owner gave it. +func TestTagsService_UUIDShapedName(t *testing.T) { + // The name the user typed, which happens to be UUID-shaped. + const name = "11111111-1111-1111-1111-111111111111" + // The tag's actual ID, which is what the endpoints need. + realID := uuid.New().String() + + // notFound is how the API answers an ID nothing carries. + notFound := func() client.ClientError { return clientStructuredError("Tag not found", 404) } + // namedTag is the lookup that finds the tag by that name. + namedTag := func(m *mocks.MockClient) { + m.EXPECT().ListTags(gomock.Any(), client.ListTagsRequest{ + Name: mo.Some(name), PageSize: mo.Some(int64(1)), + }).Return(client.Result[components.TagsList]{ + Metadata: okMeta(), + Data: &components.TagsList{Tags: []components.Tag{{ID: realID, Name: name}}, TotalSize: 1}, + }, nil) + } + + t.Run("get retries against the tag of that name", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), name). + Return(client.Result[components.Tag]{}, notFound()) + namedTag(m) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), realID). + Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: realID, Name: name}}, nil) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(assignmentsPage(nil, 0, ""), nil) + + res, err := New(m).GetTag(context.Background(), GetParams{TagID: identifiers.NewTagID(name)}) + require.NoError(t, err) + require.Equal(t, realID, res.Tag.ID) + }) + + t.Run("delete retries against the tag of that name", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().DeleteTag(gomock.Any(), mo.None[string](), name). + Return(client.Metadata{}, notFound()) + namedTag(m) + m.EXPECT().DeleteTag(gomock.Any(), mo.None[string](), realID).Return(okMeta(), nil) + + res, err := New(m).DeleteTag(context.Background(), DeleteParams{TagID: identifiers.NewTagID(name)}) + require.NoError(t, err) + // The result echoes what the user typed, not the ID we resolved to. + require.Equal(t, name, res.TagID) + }) + + t.Run("update retries against the tag of that name", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{TagID: name, Privacy: mo.Some("shared")}). + Return(client.Result[components.Tag]{}, notFound()) + namedTag(m) + m.EXPECT().UpdateTag(gomock.Any(), client.UpdateTagRequest{TagID: realID, Privacy: mo.Some("shared")}). + Return(client.Result[components.Tag]{Metadata: okMeta(), Data: &components.Tag{ID: realID, Name: name}}, nil) + + res, err := New(m).UpdateTag(context.Background(), UpdateParams{ + TagID: identifiers.NewTagID(name), Privacy: mo.Some("shared"), + }) + require.NoError(t, err) + require.Equal(t, realID, res.Tag.ID) + }) + + t.Run("assign retries the whole run, without double-assigning", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + // Both assets fail against the ID reading, so nothing was assigned. + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{TagID: name, AssetID: "8.8.8.8"}). + Return(client.Result[components.TagAssignment]{}, notFound()) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{TagID: name, AssetID: "1.1.1.1"}). + Return(client.Result[components.TagAssignment]{}, notFound()) + namedTag(m) + // Exactly one retry per asset against the real ID - no more. + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{TagID: realID, AssetID: "8.8.8.8"}). + Return(assignmentResult("a1", "8.8.8.8"), nil) + m.EXPECT().CreateTagAssignment(gomock.Any(), client.CreateTagAssignmentRequest{TagID: realID, AssetID: "1.1.1.1"}). + Return(assignmentResult("a2", "1.1.1.1"), nil) + + res, err := New(m).Assign(context.Background(), AssignParams{ + TagID: identifiers.NewTagID(name), AssetIDs: []string{"8.8.8.8", "1.1.1.1"}, + }) + require.NoError(t, err) + require.Len(t, res.Assignments, 2) + require.Empty(t, res.Failures) + }) + + t.Run("a UUID that is neither an ID nor a name keeps the API error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().DeleteTag(gomock.Any(), mo.None[string](), name). + Return(client.Metadata{}, notFound()) + // The name lookup comes back empty, so there is nothing to retry against. + m.EXPECT().ListTags(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagsList]{Data: &components.TagsList{}}, nil) + + _, err := New(m).DeleteTag(context.Background(), DeleteParams{TagID: identifiers.NewTagID(name)}) + require.Error(t, err) + require.Contains(t, err.Error(), "Tag not found") + }) +} + +func TestTagsService_GetTag_AssetCount(t *testing.T) { + tagUUID := uuid.New() + tagResult := client.Result[components.Tag]{ + Metadata: okMeta(), + Data: &components.Tag{ID: tagUUID.String(), Name: "my-tag", Privacy: components.TagPrivacyPrivate}, + } + + t.Run("counts assignments off the tag's own UUID", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), "my-tag").Return(tagResult, nil) + // The count uses the UUID from the tag just fetched, so a get by name + // still needs no separate resolve. + m.EXPECT().ListTagAssignments(gomock.Any(), client.ListTagAssignmentsRequest{ + TagID: tagUUID.String(), + PageSize: mo.Some(int64(1)), + }).Return(assignmentsPage([]string{"8.8.8.8"}, 7, ""), nil) + + res, err := New(m).GetTag(context.Background(), GetParams{ + TagID: identifiers.NewTagID("my-tag"), + }) + require.NoError(t, err) + require.NotNil(t, res.Tag.AssetCount) + require.Equal(t, int64(7), *res.Tag.AssetCount) + require.NoError(t, res.PartialError) + }) + + t.Run("a zero count is reported, not omitted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), "my-tag").Return(tagResult, nil) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(assignmentsPage(nil, 0, ""), nil) + + res, err := New(m).GetTag(context.Background(), GetParams{ + TagID: identifiers.NewTagID("my-tag"), + }) + require.NoError(t, err) + // Non-nil, so an untagged tag renders "Assets: 0" rather than dropping + // the row and reading like the count was never taken. + require.NotNil(t, res.Tag.AssetCount) + require.Equal(t, int64(0), *res.Tag.AssetCount) + }) + + t.Run("count failure keeps the tag and reports a partial error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), "my-tag").Return(tagResult, nil) + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()). + Return(client.Result[components.TagAssignmentsList]{}, clientStructuredError("Permission denied", 403)) + + res, err := New(m).GetTag(context.Background(), GetParams{ + TagID: identifiers.NewTagID("my-tag"), + }) + require.NoError(t, err) + require.Equal(t, "my-tag", res.Tag.Name) + require.Nil(t, res.Tag.AssetCount) + require.Error(t, res.PartialError) + require.Contains(t, res.PartialError.Error(), "Permission denied") + }) + + t.Run("a tag-less response is not counted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := mocks.NewMockClient(ctrl) + m.EXPECT().GetTag(gomock.Any(), mo.None[string](), "my-tag"). + Return(client.Result[components.Tag]{Metadata: okMeta()}, nil) + // With no tag ID there is nothing to count against, so the assignments + // endpoint must not be asked. + m.EXPECT().ListTagAssignments(gomock.Any(), gomock.Any()).Times(0) + + res, err := New(m).GetTag(context.Background(), GetParams{ + TagID: identifiers.NewTagID("my-tag"), + }) + require.NoError(t, err) + require.Nil(t, res.Tag.AssetCount) + require.NoError(t, res.PartialError) + }) +} diff --git a/internal/app/tags/validate.go b/internal/app/tags/validate.go new file mode 100644 index 0000000..011a370 --- /dev/null +++ b/internal/app/tags/validate.go @@ -0,0 +1,141 @@ +package tags + +import ( + "slices" + "time" + + "github.com/samber/mo" + + "github.com/censys/censys-sdk-go/models/operations" + + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +// validOrderBy is the set of accepted --order-by values, sourced from the SDK's +// generated enum so it stays in sync with the API contract. +var validOrderBy = []string{ + string(operations.V3TagsListTagsQueryParamOrderByNameAsc), + string(operations.V3TagsListTagsQueryParamOrderByNameDesc), + string(operations.V3TagsListTagsQueryParamOrderByCreatedAtAsc), + string(operations.V3TagsListTagsQueryParamOrderByCreatedAtDesc), + string(operations.V3TagsListTagsQueryParamOrderByUpdatedAtAsc), + string(operations.V3TagsListTagsQueryParamOrderByUpdatedAtDesc), +} + +// validPrivacy is the set of accepted --privacy values, sourced from the SDK's +// generated enum. +var validPrivacy = []string{ + string(operations.PrivacyPrivate), + string(operations.PrivacyShared), +} + +// validAssignmentsOrderBy is the set of accepted --order-by values for +// assignments, which sort by creation time only — hence separate from validOrderBy. +var validAssignmentsOrderBy = []string{ + string(operations.V3TagsListAssignmentsQueryParamOrderByCreateTimeAsc), + string(operations.V3TagsListAssignmentsQueryParamOrderByCreateTimeDesc), +} + +// validAssetType is the set of accepted --asset-type values, from the SDK enum. +var validAssetType = []string{ + string(operations.AssetTypeHost), + string(operations.AssetTypeWebProperty), + string(operations.AssetTypeCertificate), +} + +// validOperationType is the set of accepted --type values for operations, from +// the SDK enum. Note these are the bare operations.Type consts, distinct from +// the components.TagOperationType enum on the response. +var validOperationType = []string{ + string(operations.TypeBulkCreate), + string(operations.TypeBulkDelete), +} + +// validOperationStatus is the set of accepted --status values for operations. +// Note limit_reached, and that the in-flight state is running (no in_progress). +var validOperationStatus = []string{ + string(operations.StatusPending), + string(operations.StatusRunning), + string(operations.StatusSucceeded), + string(operations.StatusLimitReached), + string(operations.StatusFailed), + string(operations.StatusCancelled), +} + +// validOperationsOrderBy is the set of accepted --order-by values for +// operations, which sort by creation time only. +var validOperationsOrderBy = []string{ + string(operations.V3TagsListOperationsQueryParamOrderByCreateTimeAsc), + string(operations.V3TagsListOperationsQueryParamOrderByCreateTimeDesc), +} + +// validateOperationType checks an optional operation type against the accepted set. +func validateOperationType(opType mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("type", opType, validOperationType) +} + +// validateOperationStatus checks an optional operation status against the accepted set. +func validateOperationStatus(status mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("status", status, validOperationStatus) +} + +// validateOperationsOrderBy checks order-by against the operations set. +func validateOperationsOrderBy(orderBy mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("order-by", orderBy, validOperationsOrderBy) +} + +// validateOrderBy checks an optional order-by value against the accepted set. An +// absent value is valid (the filter is omitted from the request). +func validateOrderBy(orderBy mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("order-by", orderBy, validOrderBy) +} + +// validatePrivacy checks an optional privacy value against the accepted set. +func validatePrivacy(privacy mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("privacy", privacy, validPrivacy) +} + +// validateAssignmentsOrderBy checks order-by against the assignments set. +func validateAssignmentsOrderBy(orderBy mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("order-by", orderBy, validAssignmentsOrderBy) +} + +// validateAssetType checks an optional asset-type value against the accepted set. +func validateAssetType(assetType mo.Option[string]) cenclierrors.CencliError { + return validateEnumFilter("asset-type", assetType, validAssetType) +} + +// ValidateTimeWindow rejects an impossible created-before/created-after pair, +// which the API answers with an empty page — easily mistaken for "nothing +// matched". Exported so a command can reject the pair before it resolves a +// service (and so needs credentials), without restating the rule. +func ValidateTimeWindow(before, after mo.Option[time.Time]) cenclierrors.CencliError { + if !before.IsPresent() || !after.IsPresent() { + return nil + } + if before.MustGet().Before(after.MustGet()) { + return NewInvalidTimeWindowError("created-before must be after created-after") + } + return nil +} + +// validatePaginationParams rejects pagination values that would fetch nothing. +func validatePaginationParams(pageSize, maxPages mo.Option[uint64]) cenclierrors.CencliError { + if pageSize.IsPresent() && pageSize.MustGet() == 0 { + return NewInvalidPaginationParamsError("page size must be greater than 0") + } + if maxPages.IsPresent() && maxPages.MustGet() == 0 { + return NewInvalidPaginationParamsError("max pages must be greater than 0") + } + return nil +} + +func validateEnumFilter(filter string, value mo.Option[string], supported []string) cenclierrors.CencliError { + if !value.IsPresent() { + return nil + } + if !slices.Contains(supported, value.MustGet()) { + return NewInvalidEnumFilterError(filter, value.MustGet(), supported) + } + return nil +} diff --git a/internal/command/args.go b/internal/command/args.go index f110e2e..5aeb395 100644 --- a/internal/command/args.go +++ b/internal/command/args.go @@ -53,3 +53,12 @@ func RangeArgs(min, max int) PositionalArgs { return nil } } + +func MinimumNArgs(n int) PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if err := cobra.MinimumNArgs(n)(cmd, args); err != nil { + return NewArgCountError(err) + } + return nil + } +} diff --git a/internal/command/context.go b/internal/command/context.go index a6b62a2..8a80618 100644 --- a/internal/command/context.go +++ b/internal/command/context.go @@ -19,6 +19,7 @@ import ( "github.com/censys/cencli/internal/app/organizations" "github.com/censys/cencli/internal/app/search" "github.com/censys/cencli/internal/app/streaming" + "github.com/censys/cencli/internal/app/tags" "github.com/censys/cencli/internal/app/view" "github.com/censys/cencli/internal/config" "github.com/censys/cencli/internal/pkg/cenclierrors" @@ -48,6 +49,7 @@ type Context struct { censeyeSvc censeye.Service creditsSvc credits.Service orgSvc organizations.Service + tagsSvc tags.Service } // ContextOpts are functional options for configuring Context @@ -409,6 +411,27 @@ func WithEnrichService(svc enrich.Service) ContextOpts { return func(c *Context) { c.enrichSvc = svc } } +// TagsService attempts to provide a TagsService to the caller. +// If it is not already set and is unable to be instantiated, it will return an error. +func (c *Context) TagsService() (tags.Service, cenclierrors.CencliError) { + if c.tagsSvc != nil { + return c.tagsSvc, nil + } + if c.censysClient == nil { + return nil, client.NewCensysClientNotConfiguredError() + } + // Memoize the service instance since it's stateless and thread-safe for reuse + c.tagsSvc = tags.New(c.censysClient) + return c.tagsSvc, nil +} + +// WithTagsService injects an instantiated TagsService to the Context. +// This should only be used in tests, as in the application, +// the TagsService will be instantiated on demand. +func WithTagsService(svc tags.Service) ContextOpts { + return func(c *Context) { c.tagsSvc = svc } +} + // SearchService attempts to provide a SearchService to the caller. // If it is not already set and is unable to be instantiated, it will return an error. func (c *Context) SearchService() (search.Service, cenclierrors.CencliError) { diff --git a/internal/command/root/root.go b/internal/command/root/root.go index f93f5a4..539197e 100644 --- a/internal/command/root/root.go +++ b/internal/command/root/root.go @@ -18,6 +18,7 @@ import ( historycmd "github.com/censys/cencli/internal/command/history" orgcmd "github.com/censys/cencli/internal/command/org" searchcmd "github.com/censys/cencli/internal/command/search" + tagscmd "github.com/censys/cencli/internal/command/tags" versioncmd "github.com/censys/cencli/internal/command/versioncmd" "github.com/censys/cencli/internal/command/view" "github.com/censys/cencli/internal/config" @@ -76,6 +77,7 @@ func (c *Command) Init() error { censeyecmd.NewCenseyeCommand(c.Context), creditscmd.NewCreditsCommand(c.Context), orgcmd.NewOrgCommand(c.Context), + tagscmd.NewTagsCommand(c.Context), ) } diff --git a/internal/command/tags/assign.go b/internal/command/tags/assign.go new file mode 100644 index 0000000..f54477f --- /dev/null +++ b/internal/command/tags/assign.go @@ -0,0 +1,419 @@ +package tags + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/term" + "github.com/censys/cencli/internal/pkg/ui/form" +) + +const ( + assignCmdName = "assign" + + // assignIndexLagNote warns that a fresh assignment is not immediately + // searchable. Printed by both input modes, since both mutate assignments. + assignIndexLagNote = "Note: newly assigned tags may take a few minutes to appear in `tags:` search results." +) + +// AssignCommand implements `tags assign [asset...]`, linking a tag either +// to explicit assets (given positionally or via --input-file) or, with --query, +// to every asset matching a CenQL query. The query form submits an asynchronous +// bulk job and reports the operation tracking it. +type AssignCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags assignCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + assetIDs []string + // bulk state - only meaningful when bulk is true + bulk bool + query string + maxAssets mo.Option[int64] + wait bool + timeout mo.Option[time.Duration] + yes bool + // result stores the explicit-mode assignment outcome for rendering + result tags.AssignResult + // operation stores the bulk job for rendering: the submitted operation, then + // the finished one once --wait has polled it + operation tags.TagOperation + // seams - overridable in tests; defaulted in NewAssignCommand + confirm func(ctx context.Context, message string) (bool, error) + stdinIsTTY func() bool +} + +type assignCommandFlags struct { + orgID flags.OrgIDFlag + inputFile flags.FileFlag + query flags.StringFlag + maxAssets flags.IntegerFlag + wait flags.BoolFlag + timeout flags.HumanDurationFlag + yes flags.BoolFlag +} + +// assignedAsset is the data-mode payload for a single assignment outcome. +// Error is the one-line reason, and ErrorStatus the HTTP status behind it, so a +// script can branch on the code without parsing a message. +type assignedAsset struct { + Asset string `json:"asset" yaml:"asset"` + AssignmentID string `json:"assignment_id,omitempty" yaml:"assignment_id,omitempty"` + AssetType string `json:"asset_type,omitempty" yaml:"asset_type,omitempty"` + PlatformRef string `json:"platform_ref,omitempty" yaml:"platform_ref,omitempty"` + Assigned bool `json:"assigned" yaml:"assigned"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` + ErrorStatus *int64 `json:"error_status,omitempty" yaml:"error_status,omitempty"` +} + +var _ command.Command = (*AssignCommand)(nil) + +func NewAssignCommand(cmdContext *command.Context) *AssignCommand { + return &AssignCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + confirm: form.Confirm, + stdinIsTTY: func() bool { return term.IsTTY(os.Stdin) }, + } +} + +func (c *AssignCommand) Use() string { + return fmt.Sprintf("%s [asset...]", assignCmdName) +} + +func (c *AssignCommand) Short() string { + return "Assign a tag to one or more assets" +} + +func (c *AssignCommand) Long() string { + return `Assign a tag, by its name or UUID, to one or more assets (host IPs, certificate SHA-256 fingerprints, or web property hostname:port). + +Assets can be passed as positional arguments or read from a file (or STDIN) with --input-file. Assets of different types can be mixed in a single call. Each asset is assigned independently: if one fails the rest still proceed, and the per-asset outcomes are reported. + +Use --query instead to assign the tag to every asset matching a CenQL query. That starts an asynchronous bulk job and reports the operation tracking it; the two input modes cannot be combined. Bulk assignment always asks for confirmation unless --yes is set.` +} + +func (c *AssignCommand) Examples() []string { + return []string{ + " ", + " ", + " ", + " ", + " # asset types can be mixed", + " --input-file ", + " --input-file - # read assets from STDIN", + " --query 'host.services.port: 22' # assign every matching asset", + " --query 'host.services.port: 22' --max-assets 1000", + " --query 'host.services.port: 22' --wait # poll until the job finishes", + } +} + +func (c *AssignCommand) Args() command.PositionalArgs { + // At least the tag; assets may instead come from --input-file or --query. + return command.MinimumNArgs(1) +} + +func (c *AssignCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *AssignCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *AssignCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.inputFile = flags.NewFileFlag(c.Flags(), false, "input-file", "i", "file to read the assets from (or - for STDIN). Overrides positional asset arguments.") + c.flags.query = flags.NewStringFlag(c.Flags(), false, "query", "", "", + "CenQL query selecting the assets to tag. Starts a bulk job instead of assigning explicit assets.") + c.flags.maxAssets = flags.NewIntegerFlag( + c.Flags(), + false, + "max-assets", + "", + mo.None[int64](), + "cap the number of assets a bulk job tags (requires --query). The effective cap is the smaller of this and your plan's tag asset limit.", + mo.Some[int64](0), + mo.None[int64](), + ) + c.flags.wait = flags.NewBoolFlag(c.Flags(), "wait", "w", false, + "poll the bulk job until it reaches a final status (requires --query)") + c.flags.timeout = flags.NewHumanDurationFlag(c.Flags(), false, "timeout", "", + mo.Some(defaultWaitTimeout), "how long to wait before giving up (requires --wait) - use 0 for no limit") + c.flags.yes = flags.NewBoolFlag(c.Flags(), "yes", "y", false, + "skip the confirmation prompt (requires --query)") + return nil +} + +func (c *AssignCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + + if err := c.parseModeFlags(cmd, args); err != nil { + return err + } + + if !c.bulk { + c.assetIDs, err = gatherAssetIDs(cmd, c.flags.inputFile, args) + if err != nil { + return err + } + } else if !c.yes && !c.stdinIsTTY() { + // Gate the confirmation before resolving the service so a non-interactive + // invocation without --yes fails with a clear confirmation error rather + // than submitting a large job silently (and before any auth is required). + return NewConfirmationRequiredError() + } + + return c.resolveTagsService() +} + +// parseModeFlags decides between explicit and bulk assignment and rejects the +// combinations that cannot mean anything. Bulk is only ever chosen by --query; +// it is never inferred from missing asset arguments. +func (c *AssignCommand) parseModeFlags(cmd *cobra.Command, args []string) cenclierrors.CencliError { + query, err := c.flags.query.Value() + if err != nil { + return err + } + c.bulk = cmd.Flags().Changed("query") + c.query = strings.TrimSpace(query) + + if c.bulk { + // A blank query would match nothing, so reject it before it can reach a + // confirmation prompt or spend an operation. + if c.query == "" { + return tags.NewEmptyQueryError() + } + if len(args) > 1 || c.flags.inputFile.IsSet() { + return NewAssignModeConflictError() + } + } + + // Flags that only steer a bulk job would silently do nothing in explicit mode. + // --yes is one of them: explicit assignment never prompts. + for _, name := range []string{"max-assets", "wait", "timeout", "yes"} { + if !c.bulk && cmd.Flags().Changed(name) { + return NewFlagRequiresQueryError(name) + } + } + + c.maxAssets, err = c.flags.maxAssets.Value() + if err != nil { + return err + } + c.yes, err = c.flags.yes.Value() + if err != nil { + return err + } + + c.wait, c.timeout, err = parseWaitFlags(cmd, c.flags.wait, c.flags.timeout) + return err +} + +func (c *AssignCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + "bulk", c.bulk, + ) + + if c.bulk { + return c.runBulk(cmd, logger.With("wait", c.wait, "max_assets_set", c.maxAssets.IsPresent())) + } + return c.runExplicit(cmd, logger.With("count", len(c.assetIDs))) +} + +// runExplicit assigns the tag to each given asset, one request per asset. +func (c *AssignCommand) runExplicit(cmd *cobra.Command, logger *slog.Logger) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + logger, + "Assigning tag...", + func(pctx context.Context) cenclierrors.CencliError { + var assignErr cenclierrors.CencliError + c.result, assignErr = c.tagsSvc.Assign(pctx, tags.AssignParams{ + OrgID: c.orgID, + TagID: c.tagID, + AssetIDs: c.assetIDs, + }) + return assignErr + }, + ) + if err != nil { + logger.Debug("assign tag failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.assignmentViews()); renderErr != nil { + return renderErr + } + + if len(c.result.Assignments) > 0 { + printNote(c.Config().Quiet, assignIndexLagNote) + } + + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) + } + + // The results are already rendered; this only drives the exit code. + if len(c.result.Assignments) == 0 && len(c.result.Failures) > 0 { + return NewAllAssetsFailedError(len(c.result.Failures), len(c.assetIDs), "assigned") + } + + return nil +} + +// runBulk submits a query-driven bulk job and reports the operation tracking it, +// optionally polling that operation until it finishes. +func (c *AssignCommand) runBulk(cmd *cobra.Command, logger *slog.Logger) cenclierrors.CencliError { + if !c.yes { + confirmed, err := confirmAction(cmd.Context(), c.confirm, c.confirmMessage()) + if err != nil { + return err + } + if !confirmed { + formatter.Println(formatter.Stderr, "Assignment aborted.") + return nil + } + } + + var submitted tags.BulkAssignResult + err := c.WithProgress( + cmd.Context(), + logger, + "Submitting bulk assignment...", + func(pctx context.Context) cenclierrors.CencliError { + var submitErr cenclierrors.CencliError + submitted, submitErr = c.tagsSvc.BulkAssign(pctx, tags.BulkAssignParams{ + OrgID: c.orgID, + TagID: c.tagID, + Query: c.query, + MaxAssets: c.maxAssets, + }) + return submitErr + }, + ) + if err != nil { + logger.Debug("submit bulk assignment failed", "error", err) + return err + } + + c.operation = submitted.Operation + c.PrintAppResponseMeta(submitted.Meta) + + // The job now exists server-side whatever happens next, so any exit that + // leaves it unfinished says how to pick it back up. + if c.wait { + if waitErr := c.waitForSubmitted(cmd.Context(), logger); waitErr != nil { + return waitErr + } + } + + if renderErr := c.PrintData(c, c.operation); renderErr != nil { + return renderErr + } + + quiet := c.Config().Quiet + if !c.wait { + printOperationTrackHint(quiet, c.tagID.String(), c.operation.ID) + printNote(quiet, assignIndexLagNote) + return nil + } + + if statusErr := reportOperationTerminalStatus(c.operation); statusErr != nil { + return statusErr + } + printNote(quiet, assignIndexLagNote) + return nil +} + +// waitForSubmitted polls the job just submitted, replacing the operation being +// rendered with the finished one. +func (c *AssignCommand) waitForSubmitted(ctx context.Context, logger *slog.Logger) cenclierrors.CencliError { + operation, err := followSubmittedOperation(ctx, c.BaseCommand, logger, c.tagsSvc, tags.WaitParams{ + OrgID: c.orgID, + TagID: c.tagID, + OperationID: c.operation.ID, + Timeout: c.timeout, + }) + if err != nil { + logger.Debug("wait for bulk assignment failed", "error", err) + return err + } + + c.operation = operation + return nil +} + +// confirmMessage spells out what a bulk assignment is about to do, including the +// cap that will actually apply. +func (c *AssignCommand) confirmMessage() string { + limit := "your plan's tag asset limit" + if c.maxAssets.IsPresent() && c.maxAssets.MustGet() > 0 { + limit = fmt.Sprintf("at most %d asset(s)", c.maxAssets.MustGet()) + } + return fmt.Sprintf("Assign tag %q to every asset matching %q (%s)?", + c.tagID.String(), c.query, limit) +} + +func (c *AssignCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// assignmentViews builds the render payload: successes first, then failures. +func (c *AssignCommand) assignmentViews() []assignedAsset { + views := make([]assignedAsset, 0, len(c.result.Assignments)+len(c.result.Failures)) + for _, a := range c.result.Assignments { + views = append(views, assignedAsset{ + Asset: a.AssetID, + AssignmentID: a.ID, + AssetType: a.AssetType, + PlatformRef: a.PlatformRef, + Assigned: true, + }) + } + types := assetTypesByID(c.assetIDs) + for _, f := range c.result.Failures { + views = append(views, assignedAsset{ + Asset: f.AssetID, + AssetType: types[f.AssetID], + Assigned: false, + Error: f.Detail, + ErrorStatus: f.Status.ToPointer(), + }) + } + return views +} diff --git a/internal/command/tags/assign_test.go b/internal/command/tags/assign_test.go new file mode 100644 index 0000000..9768ca2 --- /dev/null +++ b/internal/command/tags/assign_test.go @@ -0,0 +1,861 @@ +package tags + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +// assignSeams overrides the interactive dependencies a bulk assignment uses. The +// explicit-asset mode never touches them, so they can be left unset there. +type assignSeams struct { + confirm func(ctx context.Context, message string) (bool, error) + stdinIsTTY func() bool + // quiet stands in for the global --quiet flag, which lives on the real root + // command and so is not registered when a subcommand is mounted alone. + quiet bool +} + +func runAssignCommand(t *testing.T, svc apptags.Service, args []string, stdin io.Reader) (stdout, stderr string, err error) { + t.Helper() + return runAssignCommandWithSeams(t, svc, assignSeams{}, args, stdin) +} + +func runAssignCommandWithSeams( + t *testing.T, + svc apptags.Service, + seams assignSeams, + args []string, + stdin io.Reader, +) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + if seams.quiet { + // PreRun re-reads the config from viper, so setting the struct field would + // be overwritten; viper is also where the real --quiet flag lands. + viper.Set("quiet", true) + } + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + cmd := NewAssignCommand(cmdContext) + if seams.confirm != nil { + cmd.confirm = seams.confirm + } + if seams.stdinIsTTY != nil { + cmd.stdinIsTTY = seams.stdinIsTTY + } + rootCmd, buildErr := command.RootCommandToCobra(cmd) + require.NoError(t, buildErr) + + if stdin != nil { + rootCmd.SetIn(stdin) + } + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +// assignResult builds a service result with the given assigned assets and +// optional failures, echoing the tag identifier. +func assignResult(tagID string, assigned []string, failures map[string]string) apptags.AssignResult { + res := apptags.AssignResult{Meta: okMeta(), TagID: tagID} + for _, a := range assigned { + res.Assignments = append(res.Assignments, apptags.Assignment{ + ID: a + "-id", AssetID: a, AssetType: "host", TagID: tagID, + PlatformRef: "https://platform.censys.io/hosts/" + a, + }) + } + for asset, msg := range failures { + res.Failures = append(res.Failures, apptags.AssignmentFailure{ + AssetID: asset, + Err: cenclierrors.NewCencliError(errors.New(msg)), + // The service reduces every failure to a one-line Detail; the views + // read that, not Err, so the fixture has to carry it too. + Detail: msg, + Status: mo.Some(int64(409)), + }) + } + // Mirrors the service: a partial error only when something also succeeded. + // A run where every asset failed is not partial, and the command turns it + // into a non-zero exit itself. + if len(res.Failures) > 0 && len(res.Assignments) > 0 { + res.PartialError = cenclierrors.ToPartialError( + cenclierrors.NewCencliError(errors.New("some assets failed"))) + } + return res +} + +// assignNoCallService returns a service that must not be called (validation is +// expected to fail before the service is reached). +func assignNoCallService(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) +} + +func TestTagsAssignCommand(t *testing.T) { + testCases := []struct { + name string + args []string + stdin io.Reader + seams assignSeams + service func(t *testing.T, ctrl *gomock.Controller) apptags.Service + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "positional assets assigned - short output with index-lag note", + args: []string{"alpha", "8.8.8.8", "1.1.1.1"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignParams) (apptags.AssignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return assignResult("alpha", []string{"8.8.8.8", "1.1.1.1"}, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stdout, "1.1.1.1") + require.Contains(t, stderr, "few minutes") + }, + }, + { + name: "comma-separated positional assets are split", + args: []string{"alpha", "8.8.8.8,1.1.1.1"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignParams) (apptags.AssignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return assignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "mixed asset types are accepted in one call", + args: []string{"alpha", "8.8.8.8", "platform.censys.io:443"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignParams) (apptags.AssignResult, cenclierrors.CencliError) { + require.ElementsMatch(t, []string{"8.8.8.8", "platform.censys.io:443"}, p.AssetIDs) + return assignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "stdin input via --input-file -", + args: []string{"alpha", "--input-file", "-"}, + stdin: bytes.NewBufferString("8.8.8.8\n1.1.1.1\n"), + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignParams) (apptags.AssignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return assignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "unknown asset is rejected before the service is called", + args: []string{"alpha", "8.8.8.8", "not-an-asset"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return assignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "not-an-asset") + }, + }, + { + name: "no assets is an error", + args: []string{"alpha"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return assignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "empty tag id is rejected", + args: []string{" ", "8.8.8.8"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return assignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + }, + }, + { + name: "partial failure is surfaced to stderr but data still renders", + args: []string{"alpha", "8.8.8.8", "1.1.1.1"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).Return( + assignResult("alpha", []string{"8.8.8.8"}, map[string]string{"1.1.1.1": "Forbidden"}), + nil, + ) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stdout, "1.1.1.1") + require.Contains(t, stderr, "some assets failed") + }, + }, + { + name: "json output renders per-asset payload", + args: []string{"alpha", "8.8.8.8", "--output-format", "json"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).Return( + assignResult("alpha", []string{"8.8.8.8"}, nil), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"asset": "8.8.8.8"`) + require.Contains(t, stdout, `"assigned": true`) + }, + }, + { + // Explicit assignment never prompts, so --yes has nothing to skip. + // It is rejected rather than ignored, like every other bulk-only flag. + name: "--yes is rejected in explicit mode", + args: []string{"alpha", "8.8.8.8", "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return assignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--yes only applies to a bulk assignment") + }, + }, + { + name: "--quiet suppresses the index-lag note in explicit mode", + args: []string{"alpha", "8.8.8.8"}, + seams: assignSeams{quiet: true}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).Return( + assignResult("alpha", []string{"8.8.8.8"}, nil), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Empty(t, stderr) + }, + }, + { + name: "org id flag is threaded to the service", + args: []string{"alpha", "8.8.8.8", "--org-id", "11111111-1111-1111-1111-111111111111"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignParams) (apptags.AssignResult, cenclierrors.CencliError) { + require.True(t, p.OrgID.IsPresent()) + return assignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runAssignCommandWithSeams(t, tc.service(t, ctrl), tc.seams, tc.args, tc.stdin) + tc.assert(t, stdout, stderr, err) + }) + } +} + +// bulkSubmitted is what the service returns for an accepted bulk job. +func bulkSubmitted(status string) apptags.BulkAssignResult { + return apptags.BulkAssignResult{Meta: okMeta(), Operation: operation(testOperationID, status)} +} + +// bulkNoCallService asserts no bulk submit happens, proving the input was +// rejected at the command boundary. +func bulkNoCallService(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).Times(0) + return m +} + +// bulkSubmitOnly expects a submit and no polling. +func bulkSubmitOnly(ctrl *gomock.Controller, status string) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return(bulkSubmitted(status), nil) + return m +} + +// bulkSubmitAndWait expects a submit followed by polling that ends on the given +// status. +func bulkSubmitAndWait(ctrl *gomock.Controller, finalStatus string) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return(bulkSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{Meta: okMeta(), Operation: finishedOperation(finalStatus)}, nil) + return m +} + +// alwaysTTY makes the command believe it can prompt. +func alwaysTTY() func() bool { return func() bool { return true } } + +// TestTagsAssignCommand_AllAssetsFail pins the contract for a run where no asset +// succeeded: the per-asset results still render in every output mode, and the +// exit code is non-zero. Re-assigning already-tagged assets makes this the +// common failure, not an edge case - the API returns 409 for every one. +func TestTagsAssignCommand_AllAssetsFail(t *testing.T) { + allFailed := func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).Return( + assignResult("alpha", nil, map[string]string{ + "8.8.8.8": "assignment already exists", + "1.1.1.1": "assignment already exists", + }), nil) + return m + } + + t.Run("short output lists every failed asset and exits non-zero", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runAssignCommand(t, allFailed(ctrl), + []string{"alpha", "8.8.8.8", "1.1.1.1"}, nil) + + require.Error(t, err) + require.Equal(t, 1, formatter.ExitCode(err)) + // Both assets named, not just whichever failed first. + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stdout, "1.1.1.1") + require.Contains(t, stdout, "already exists") + require.Contains(t, err.Error(), "2 of 2 failed") + // Nothing was tagged, so the index-lag note would be nonsense. + require.NotContains(t, stderr, "few minutes") + }) + + t.Run("json output still emits the full array", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, _, err := runAssignCommand(t, allFailed(ctrl), + []string{"alpha", "8.8.8.8", "1.1.1.1", "--output-format", "json"}, nil) + + require.Error(t, err) + // The whole point: a script gets parseable results alongside the failure, + // where it previously got an empty stdout. + require.Contains(t, stdout, `"asset": "8.8.8.8"`) + require.Contains(t, stdout, `"asset": "1.1.1.1"`) + require.Contains(t, stdout, `"assigned": false`) + // A one-line reason plus the status as a number, not the API's whole + // problem document escaped into a string. + require.Contains(t, stdout, `"error": "assignment already exists"`) + require.Contains(t, stdout, `"error_status": 409`) + require.NotContains(t, stdout, `\"title\"`) + }) + + t.Run("a cut-short run does not claim every asset failed", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // One failure recorded out of three assets: the loop stopped early, so + // the message must count what was attempted, not what was asked for. + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).Return( + assignResult("alpha", nil, map[string]string{"8.8.8.8": "boom"}), nil) + + _, _, err := runAssignCommand(t, m, + []string{"alpha", "8.8.8.8", "1.1.1.1", "9.9.9.9"}, nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "1 of 3 failed") + }) +} + +func TestTagsAssignCommand_Bulk(t *testing.T) { + const query = "host.services.port: 22" + + testCases := []struct { + name string + args []string + seams assignSeams + service func(t *testing.T, ctrl *gomock.Controller) apptags.Service + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "--query with positional assets is a mode conflict", + args: []string{"alpha", "8.8.8.8", "--query", query}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be combined with explicit assets") + }, + }, + { + name: "--query with --input-file is a mode conflict", + args: []string{"alpha", "--input-file", "-", "--query", query}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be combined with explicit assets") + }, + }, + { + name: "blank --query is rejected", + args: []string{"alpha", "--query", " "}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--query must not be empty") + }, + }, + { + name: "--max-assets without --query is rejected", + args: []string{"alpha", "8.8.8.8", "--max-assets", "10"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--max-assets only applies to a bulk assignment") + }, + }, + { + name: "--wait without --query is rejected", + args: []string{"alpha", "8.8.8.8", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--wait only applies to a bulk assignment") + }, + }, + { + name: "--timeout without --wait is rejected", + args: []string{"alpha", "--query", query, "--timeout", "5m"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--timeout only applies while polling") + }, + }, + { + name: "non-interactive without --yes refuses to submit", + args: []string{"alpha", "--query", query}, + seams: assignSeams{stdinIsTTY: func() bool { return false }}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "confirmation required") + }, + }, + { + name: "declining the prompt aborts without submitting", + args: []string{"alpha", "--query", query}, + seams: assignSeams{ + stdinIsTTY: alwaysTTY(), + confirm: func(context.Context, string) (bool, error) { return false, nil }, + }, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, "Assignment aborted.") + }, + }, + { + name: "--yes submits and reports the operation with a track hint", + args: []string{"alpha", "--query", query, "--yes"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkAssignParams) (apptags.BulkAssignResult, cenclierrors.CencliError) { + require.Equal(t, query, p.Query) + require.Equal(t, "alpha", p.TagID.String()) + require.False(t, p.MaxAssets.IsPresent()) + return bulkSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Tag Operation") + require.Contains(t, stdout, testOperationID) + require.Contains(t, stderr, "Track with: censys tags operations get alpha "+testOperationID) + require.Contains(t, stderr, "few minutes") + }, + }, + { + name: "--max-assets is threaded to the service", + args: []string{"alpha", "--query", query, "--max-assets", "250", "--yes"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkAssignParams) (apptags.BulkAssignResult, cenclierrors.CencliError) { + require.True(t, p.MaxAssets.IsPresent()) + require.Equal(t, int64(250), p.MaxAssets.MustGet()) + return bulkSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "org id is threaded to the service", + args: []string{"alpha", "--query", query, "--yes", "--org-id", "11111111-1111-1111-1111-111111111111"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkAssignParams) (apptags.BulkAssignResult, cenclierrors.CencliError) { + require.True(t, p.OrgID.IsPresent()) + return bulkSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "json output renders the operation payload", + args: []string{"alpha", "--query", query, "--yes", "--output-format", "json"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkSubmitOnly(ctrl, "pending") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"status": "pending"`) + require.Contains(t, stdout, `"type": "bulk_create"`) + }, + }, + { + name: "--wait polls the submitted operation and renders the final status", + args: []string{"alpha", "--query", query, "--yes", "--wait"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return(bulkSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.WaitParams) (apptags.GetOperationResult, cenclierrors.CencliError) { + // The wait must follow the operation the submit just created. + require.Equal(t, testOperationID, p.OperationID) + require.True(t, p.Timeout.IsPresent()) + return apptags.GetOperationResult{ + Meta: okMeta(), Operation: finishedOperation("succeeded"), + }, nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "succeeded") + // Waiting to the end replaces the hint with the outcome. + require.NotContains(t, stderr, "Track with") + }, + }, + { + name: "--wait ending at the asset limit warns but succeeds", + args: []string{"alpha", "--query", query, "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkSubmitAndWait(ctrl, "limit_reached") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, "asset limit") + require.Contains(t, stderr, "few minutes") + }, + }, + { + name: "--wait ending failed exits non-zero", + args: []string{"alpha", "--query", query, "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkSubmitAndWait(ctrl, "failed") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + // The payload still renders; only the exit code reports the outcome. + require.Contains(t, stdout, "failed") + }, + }, + { + name: "--wait ending cancelled exits non-zero", + args: []string{"alpha", "--query", query, "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkSubmitAndWait(ctrl, "cancelled") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stdout, "cancelled") + }, + }, + { + name: "interrupting the wait keeps the job and prints how to follow it", + args: []string{"alpha", "--query", query, "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return(bulkSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{}, cenclierrors.NewInterruptedError()) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stderr, "continues server-side") + require.Contains(t, stderr, "Track with: censys tags operations get alpha "+testOperationID) + }, + }, + { + name: "a wait that times out still points at the running job", + args: []string{"alpha", "--query", query, "--yes", "--wait", "--timeout", "5s"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return(bulkSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{}, + apptags.NewOperationWaitTimeoutError(testOperationID, "running", 5*time.Second)) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stderr, "Track with: censys tags operations get alpha "+testOperationID) + }, + }, + { + name: "--timeout 0 waits without a limit", + args: []string{"alpha", "--query", query, "--yes", "--wait", "--timeout", "0"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return(bulkSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.WaitParams) (apptags.GetOperationResult, cenclierrors.CencliError) { + // Zero means unbounded, not "give up before the first poll". + require.False(t, p.Timeout.IsPresent()) + return apptags.GetOperationResult{ + Meta: okMeta(), Operation: finishedOperation("succeeded"), + }, nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "a negative --timeout is rejected", + args: []string{"alpha", "--query", query, "--yes", "--wait", "--timeout", "-5m"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--timeout must not be negative") + }, + }, + { + name: "--quiet suppresses the hint and the index-lag note", + args: []string{"alpha", "--query", query, "--yes"}, + seams: assignSeams{quiet: true}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkSubmitOnly(ctrl, "pending") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Empty(t, stderr) + // The operation itself is the result, so it still renders. + require.Contains(t, stdout, testOperationID) + }, + }, + { + name: "a tag name needing quoting is safe to paste back", + args: []string{"my tag", "--query", query, "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkSubmitOnly(ctrl, "pending") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, `operations get "my tag" `+testOperationID) + }, + }, + { + name: "--max-assets 0 is passed through as no explicit cap", + args: []string{"alpha", "--query", query, "--max-assets", "0", "--yes"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkAssignParams) (apptags.BulkAssignResult, cenclierrors.CencliError) { + require.True(t, p.MaxAssets.IsPresent()) + require.Equal(t, int64(0), p.MaxAssets.MustGet()) + return bulkSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "a failed submit reports the error and nothing to track", + args: []string{"alpha", "--query", query, "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkAssign(gomock.Any(), gomock.Any()).Return( + apptags.BulkAssignResult{}, cenclierrors.NewCencliError(errors.New("Permission denied"))) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.NotContains(t, stderr, "Track with") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Bulk always confirms, so default to an accepted prompt on a TTY. + seams := tc.seams + if seams.stdinIsTTY == nil { + seams.stdinIsTTY = alwaysTTY() + } + if seams.confirm == nil { + seams.confirm = func(context.Context, string) (bool, error) { return true, nil } + } + + stdout, stderr, err := runAssignCommandWithSeams(t, tc.service(t, ctrl), seams, tc.args, nil) + tc.assert(t, stdout, stderr, err) + }) + } +} + +// TestTagsAssignCommand_BulkConfirmationMessage pins what the prompt tells the +// user before they approve a job that could tag a very large number of assets. +func TestTagsAssignCommand_BulkConfirmationMessage(t *testing.T) { + testCases := []struct { + name string + args []string + contains []string + }{ + { + name: "without --max-assets the plan limit applies", + args: []string{"alpha", "--query", "host.services.port: 22"}, + contains: []string{`"alpha"`, "host.services.port: 22", "your plan's tag asset limit"}, + }, + { + name: "with --max-assets the cap is spelled out", + args: []string{"alpha", "--query", "host.services.port: 22", "--max-assets", "250"}, + contains: []string{"at most 250 asset(s)"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var prompt string + seams := assignSeams{ + stdinIsTTY: alwaysTTY(), + confirm: func(_ context.Context, message string) (bool, error) { + prompt = message + // Declining keeps the test off the submit path. + return false, nil + }, + } + + _, _, err := runAssignCommandWithSeams(t, bulkNoCallService(ctrl), seams, tc.args, nil) + require.NoError(t, err) + for _, want := range tc.contains { + require.Contains(t, prompt, want) + } + }) + } +} + +func TestTagsAssignCommand_InputFile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dir := t.TempDir() + file := filepath.Join(dir, "assets.txt") + require.NoError(t, os.WriteFile(file, []byte("8.8.8.8\n1.1.1.1\n"), 0o600)) + + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Assign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignParams) (apptags.AssignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return assignResult("alpha", p.AssetIDs, nil), nil + }) + + _, _, err := runAssignCommand(t, m, []string{"alpha", "--input-file", file}, nil) + require.NoError(t, err) +} diff --git a/internal/command/tags/assignments.go b/internal/command/tags/assignments.go new file mode 100644 index 0000000..912e41c --- /dev/null +++ b/internal/command/tags/assignments.go @@ -0,0 +1,298 @@ +package tags + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/assets" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/input" +) + +const assignmentsCmdName = "assignments" + +// AssignmentsCommand implements `tags assignments `, listing the assets a +// tag is assigned to. Supports NDJSON streaming for tags with many assets. +type AssignmentsCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags assignmentsCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + asset mo.Option[string] + assetType mo.Option[string] + createdBy mo.Option[string] + createdBefore mo.Option[time.Time] + createdAfter mo.Option[time.Time] + orderBy mo.Option[string] + pageSize mo.Option[uint64] + maxPages mo.Option[uint64] + // result stores the assignments for rendering + result tags.AssignmentsResult +} + +type assignmentsCommandFlags struct { + orgID flags.OrgIDFlag + asset flags.StringSliceFlag + assetType flags.StringFlag + createdBy flags.UUIDFlag + createdBefore flags.TimestampFlag + createdAfter flags.TimestampFlag + orderBy flags.StringFlag + pageSize flags.IntegerFlag + maxPages flags.IntegerFlag +} + +var _ command.Command = (*AssignmentsCommand)(nil) + +func NewAssignmentsCommand(cmdContext *command.Context) *AssignmentsCommand { + return &AssignmentsCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *AssignmentsCommand) Use() string { + return fmt.Sprintf("%s ", assignmentsCmdName) +} + +func (c *AssignmentsCommand) Short() string { + return "List the assets a tag is assigned to" +} + +func (c *AssignmentsCommand) Long() string { + return `List the assets a tag, given by its name or UUID, is assigned to. + +Results can be filtered by asset, asset type, creator, and creation time. Use --streaming to emit each assignment as NDJSON as it is fetched.` +} + +func (c *AssignmentsCommand) Examples() []string { + return []string{ + "my-tag # List a tag's assignments", + "my-tag --asset-type host # Only host assignments", + "my-tag --asset # Check whether one asset is assigned", + "my-tag --created-after 2025-01-01T00:00:00Z # Only recent assignments", + "my-tag --max-pages -1 # Fetch every page", + "my-tag --streaming # Emit NDJSON as assignments are fetched", + } +} + +func (c *AssignmentsCommand) Args() command.PositionalArgs { + return command.ExactArgs(1) +} + +func (c *AssignmentsCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *AssignmentsCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *AssignmentsCommand) SupportsStreaming() bool { + return true +} + +func (c *AssignmentsCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + // A slice, though only one asset is accepted: repeating the flag then lands + // in the same slice as a comma-separated list, so both spellings of "more + // than one asset" hit the one rejection below instead of silently winning. + c.flags.asset = flags.NewStringSliceFlag(c.Flags(), false, "asset", "", nil, + "filter by one asset (host IP, certificate SHA-256 fingerprint, or web property hostname:port) - giving more than one is an error") + c.flags.assetType = flags.NewStringFlag(c.Flags(), false, "asset-type", "", "", "filter by asset type (host, web_property, certificate)") + c.flags.createdBy = flags.NewUUIDFlag(c.Flags(), false, "created-by", "", mo.None[uuid.UUID](), + "filter by the UUID of the assignment's creator") + c.flags.createdBefore = flags.NewTimestampFlag(c.Flags(), false, "created-before", "", mo.None[time.Time](), "only assignments created before this time") + c.flags.createdAfter = flags.NewTimestampFlag(c.Flags(), false, "created-after", "", mo.None[time.Time](), "only assignments created after this time") + c.flags.orderBy = flags.NewStringFlag(c.Flags(), false, "order-by", "", "", "sort order (create_time_asc, create_time_desc)") + c.flags.pageSize = flags.NewIntegerFlag( + c.Flags(), + false, + "page-size", + "n", + mo.Some[int64](defaultPageSize), + "number of assignments to return per page", + mo.Some[int64](minPageSize), + mo.Some[int64](maxPageSize), + ) + c.flags.maxPages = flags.NewIntegerFlag( + c.Flags(), + false, + "max-pages", + "p", + mo.Some[int64](defaultMaxPages), + "maximum number of pages to fetch (-1 for all pages)", + mo.None[int64](), // allow custom validation in PreRun (to support -1) + mo.None[int64](), // no maximum + ) + return nil +} + +func (c *AssignmentsCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + + if err := c.parseFilterFlags(); err != nil { + return err + } + if err := c.parsePaginationFlags(); err != nil { + return err + } + + return c.resolveTagsService() +} + +func (c *AssignmentsCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + "asset_set", c.asset.IsPresent(), + "assetType_set", c.assetType.IsPresent(), + "pageSize_set", c.pageSize.IsPresent(), + "maxPages_set", c.maxPages.IsPresent(), + ) + + warnFetchingAllPages(c.Config().Quiet, logger, c.maxPages) + + // Set up streaming output (no-op for non-streaming formats) + ctx, stopStreaming := c.WithStreamingOutput(cmd.Context(), logger) + defer stopStreaming(nil) + + err := c.WithProgress( + ctx, + logger, + "Fetching assignments...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.tagsSvc.ListAssignments(pctx, tags.AssignmentsParams{ + OrgID: c.orgID, + TagID: c.tagID, + AssetID: c.asset, + AssetType: c.assetType, + CreatedBy: c.createdBy, + CreatedBefore: c.createdBefore, + CreatedAfter: c.createdAfter, + OrderBy: c.orderBy, + PageSize: c.pageSize, + MaxPages: c.maxPages, + }) + return fetchErr + }, + ) + if err != nil { + logger.Debug("list assignments failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.result.Assignments); renderErr != nil { + return renderErr + } + + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) + } + + return nil +} + +func (c *AssignmentsCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// parseFilterFlags reads the optional filters; a blank value omits the filter. +func (c *AssignmentsCommand) parseFilterFlags() cenclierrors.CencliError { + asset, err := c.flags.asset.Value() + if err != nil { + return err + } + c.asset, err = c.parseAssetFilter(asset) + if err != nil { + return err + } + + assetType, err := c.flags.assetType.Value() + if err != nil { + return err + } + c.assetType = optionalNonEmpty(assetType) + + createdBy, err := c.flags.createdBy.Value() + if err != nil { + return err + } + c.createdBy = uuidFilterString(createdBy) + + orderBy, err := c.flags.orderBy.Value() + if err != nil { + return err + } + c.orderBy = optionalNonEmpty(orderBy) + + c.createdBefore, err = c.flags.createdBefore.Value(c.Config().DefaultTZ) + if err != nil { + return err + } + c.createdAfter, err = c.flags.createdAfter.Value(c.Config().DefaultTZ) + return err +} + +// parseAssetFilter validates the --asset filter so a mistyped asset fails fast +// instead of silently matching nothing. The endpoint filters on one asset, so +// more than one is rejected rather than silently truncated - whether they were +// comma-separated, given as a repeated flag, or both. +func (c *AssignmentsCommand) parseAssetFilter(raw []string) (mo.Option[string], cenclierrors.CencliError) { + var split []string + for _, value := range raw { + if strings.TrimSpace(value) == "" { + continue + } + split = append(split, input.SplitString(value)...) + } + if len(split) == 0 { + return mo.None[string](), nil + } + + ids, err := classifyAssetIDs(split) + if err != nil { + return mo.None[string](), err + } + if len(ids) > 1 { + return mo.None[string](), assets.NewTooManyAssetsError(len(ids), 1) + } + return mo.Some(ids[0]), nil +} + +func (c *AssignmentsCommand) parsePaginationFlags() cenclierrors.CencliError { + var err cenclierrors.CencliError + c.pageSize, c.maxPages, err = parsePaginationFlags(c.flags.pageSize, c.flags.maxPages) + return err +} diff --git a/internal/command/tags/assignments_test.go b/internal/command/tags/assignments_test.go new file mode 100644 index 0000000..c3f6e05 --- /dev/null +++ b/internal/command/tags/assignments_test.go @@ -0,0 +1,313 @@ +package tags + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +func runAssignmentsCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewAssignmentsCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func assignment(assetID string) apptags.Assignment { + return apptags.Assignment{ + ID: assetID + "-assignment", + TagID: "tag-id", + AssetID: assetID, + AssetType: "host", + PlatformRef: "https://platform.censys.io/hosts/" + assetID, + CreatedBy: "creator", + CreatedAt: time.Unix(0, 0).UTC(), + } +} + +// assignmentsNoCallService asserts the service is never reached, proving a bad +// input was rejected at the command boundary. +func assignmentsNoCallService(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).Times(0) + return m +} + +func TestTagsAssignmentsCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).Return( + apptags.AssignmentsResult{ + Meta: okMeta(), + Assignments: []apptags.Assignment{assignment("8.8.8.8"), assignment("1.1.1.1")}, + TotalSize: 2, + }, nil) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Assignments (2)") + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stdout, "host") + }, + }, + { + name: "short output reports the API total when truncated", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).Return( + apptags.AssignmentsResult{ + Meta: okMeta(), + Assignments: []apptags.Assignment{assignment("8.8.8.8")}, + TotalSize: 9, + }, nil) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Assignments (1 of 9)") + }, + }, + { + name: "empty result renders a friendly message", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).Return( + apptags.AssignmentsResult{Meta: okMeta()}, nil) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "No assignments found.") + }, + }, + { + name: "json output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).Return( + apptags.AssignmentsResult{ + Meta: okMeta(), + Assignments: []apptags.Assignment{assignment("8.8.8.8")}, + TotalSize: 1, + }, nil) + return m + }, + args: []string{"my-tag", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"asset_id"`) + require.Contains(t, stdout, `"platform_ref"`) + }, + }, + { + name: "filters and pagination parsed into params", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignmentsParams) (apptags.AssignmentsResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", p.TagID.String()) + require.Equal(t, mo.Some("8.8.8.8"), p.AssetID) + require.Equal(t, mo.Some("host"), p.AssetType) + require.Equal(t, mo.Some("f47ac10b-58cc-4372-a567-0e02b2c3d479"), p.CreatedBy) + require.Equal(t, mo.Some("create_time_asc"), p.OrderBy) + require.Equal(t, mo.Some(uint64(25)), p.PageSize) + require.Equal(t, mo.Some(uint64(3)), p.MaxPages) + require.True(t, p.CreatedAfter.IsPresent()) + require.Equal(t, 2025, p.CreatedAfter.MustGet().Year()) + return apptags.AssignmentsResult{Meta: okMeta()}, nil + }) + return m + }, + args: []string{ + "my-tag", + "--asset", "8.8.8.8", + "--asset-type", "host", + "--created-by", "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "--created-after", "2025-01-01T00:00:00Z", + "--order-by", "create_time_asc", + "--page-size", "25", + "--max-pages", "3", + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "all pages requested - max-pages left absent and usage warned", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.AssignmentsParams) (apptags.AssignmentsResult, cenclierrors.CencliError) { + require.False(t, p.MaxPages.IsPresent()) + return apptags.AssignmentsResult{Meta: okMeta()}, nil + }) + return m + }, + args: []string{"my-tag", "--max-pages", "-1"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, "fetching all pages") + }, + }, + { + name: "partial error printed after the data", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListAssignments(gomock.Any(), gomock.Any()).Return( + apptags.AssignmentsResult{ + Meta: okMeta(), + Assignments: []apptags.Assignment{assignment("8.8.8.8")}, + TotalSize: 1, + PartialError: cenclierrors.NewCencliError(context.Canceled), + }, nil) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "8.8.8.8") + require.NotEmpty(t, stderr) + }, + }, + { + name: "empty tag identifier rejected before the service", + service: assignmentsNoCallService, + args: []string{" "}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag name or ID is required") + }, + }, + { + // The API declares created_by as a UUID and 422s on anything else, so + // it is rejected here instead of costing a round trip. + name: "non-UUID --created-by rejected before the service", + service: assignmentsNoCallService, + args: []string{"my-tag", "--created-by", "not-a-uuid"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "unparseable asset filter rejected before the service", + service: assignmentsNoCallService, + args: []string{"my-tag", "--asset", "bogus%%"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "unable to infer asset type") + }, + }, + { + name: "multiple assets in the filter rejected before the service", + service: assignmentsNoCallService, + args: []string{"my-tag", "--asset", "8.8.8.8,1.1.1.1"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "only 1") + }, + }, + { + // The same mistake spelled as a repeated flag. This used to take the + // last value and exit 0, filtering on an asset the caller did not + // mean while silently dropping the one they did. + name: "repeated --asset rejected the same way as the comma form", + service: assignmentsNoCallService, + args: []string{"my-tag", "--asset", "8.8.8.8", "--asset", "1.1.1.1"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "2 assets provided, only 1") + }, + }, + { + name: "the two spellings combine into one count", + service: assignmentsNoCallService, + args: []string{"my-tag", "--asset", "8.8.8.8,1.1.1.1", "--asset", "9.9.9.9"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "3 assets provided, only 1") + }, + }, + { + name: "page-size above the API maximum rejected before the service", + service: assignmentsNoCallService, + args: []string{"my-tag", "--page-size", "1001"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "1000") + }, + }, + { + name: "zero max-pages rejected before the service", + service: assignmentsNoCallService, + args: []string{"my-tag", "--max-pages", "0"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "must be -1 or >= 1") + }, + }, + { + name: "invalid timestamp rejected before the service", + service: assignmentsNoCallService, + args: []string{"my-tag", "--created-after", "yesterday"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid timestamp") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runAssignmentsCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/create.go b/internal/command/tags/create.go new file mode 100644 index 0000000..7a10ec4 --- /dev/null +++ b/internal/command/tags/create.go @@ -0,0 +1,158 @@ +package tags + +import ( + "context" + "fmt" + "strings" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const createCmdName = "create" + +// CreateCommand implements `tags create `, creating a new tag. +type CreateCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags createCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + name string + privacy string + description mo.Option[string] + // result stores the created tag for rendering + result tags.CreateResult +} + +type createCommandFlags struct { + orgID flags.OrgIDFlag + privacy flags.StringFlag + description flags.StringFlag +} + +var _ command.Command = (*CreateCommand)(nil) + +func NewCreateCommand(cmdContext *command.Context) *CreateCommand { + return &CreateCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *CreateCommand) Use() string { + return fmt.Sprintf("%s ", createCmdName) +} + +func (c *CreateCommand) Short() string { + return "Create a new tag" +} + +func (c *CreateCommand) Long() string { + return `Create a new tag with the given name. + +Tag names must be unique within an organization. New tags are private by default; use --privacy shared to make a tag visible to all organization members.` +} + +func (c *CreateCommand) Examples() []string { + return []string{ + "my-tag # Create a private tag", + "my-tag --privacy shared # Create a shared tag", + `my-tag --description "Assets flagged for review" # Create a tag with a description`, + } +} + +func (c *CreateCommand) Args() command.PositionalArgs { + return command.ExactArgs(1) +} + +func (c *CreateCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *CreateCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *CreateCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.privacy = flags.NewStringFlag(c.Flags(), false, "privacy", "", "private", "tag visibility (private, shared)") + c.flags.description = flags.NewStringFlag(c.Flags(), false, "description", "", "", "a human-readable description of the tag") + return nil +} + +func (c *CreateCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + c.name = strings.TrimSpace(args[0]) + + privacy, err := c.flags.privacy.Value() + if err != nil { + return err + } + c.privacy = privacy + + description, err := c.flags.description.Value() + if err != nil { + return err + } + c.description = optionalNonEmpty(description) + + return c.resolveTagsService() +} + +func (c *CreateCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "privacy", c.privacy, + "description_set", c.description.IsPresent(), + ) + + err := c.WithProgress( + cmd.Context(), + logger, + "Creating tag...", + func(pctx context.Context) cenclierrors.CencliError { + var createErr cenclierrors.CencliError + c.result, createErr = c.tagsSvc.CreateTag(pctx, tags.CreateParams{ + OrgID: c.orgID, + Name: c.name, + Description: c.description, + Privacy: c.privacy, + }) + return createErr + }, + ) + if err != nil { + logger.Debug("create tag failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + return c.PrintData(c, c.result.Tag) +} + +func (c *CreateCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// RenderShort renders the created tag as a labeled detail view (TTY-aware). +func (c *CreateCommand) RenderShort() cenclierrors.CencliError { + return renderTagDetail("━━━ Tag Created ━━━", c.result.Tag) +} diff --git a/internal/command/tags/create_test.go b/internal/command/tags/create_test.go new file mode 100644 index 0000000..13991b9 --- /dev/null +++ b/internal/command/tags/create_test.go @@ -0,0 +1,193 @@ +package tags + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +func runCreateCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewCreateCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func TestTagsCreateCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return( + apptags.CreateResult{Meta: okMeta(), Tag: tag("alpha")}, + nil, + ) + return m + }, + args: []string{"alpha"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "alpha") + require.Contains(t, stdout, "Name:") + require.Contains(t, stdout, "Tag Created") + }, + }, + { + name: "success - json output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return( + apptags.CreateResult{Meta: okMeta(), Tag: tag("alpha")}, + nil, + ) + return m + }, + args: []string{"alpha", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"name": "alpha"`) + }, + }, + { + name: "default privacy is private; name threaded to service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CreateTag(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.CreateParams) (apptags.CreateResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", params.Name) + require.Equal(t, "private", params.Privacy) + require.True(t, params.Description.IsAbsent()) + return apptags.CreateResult{Meta: okMeta(), Tag: tag("my-tag")}, nil + }, + ) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "my-tag") + }, + }, + { + name: "privacy and description flags threaded to service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CreateTag(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.CreateParams) (apptags.CreateResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", params.Name) + require.Equal(t, "shared", params.Privacy) + require.True(t, params.Description.IsPresent()) + require.Equal(t, "some notes", params.Description.MustGet()) + return apptags.CreateResult{Meta: okMeta(), Tag: tag("my-tag")}, nil + }, + ) + return m + }, + args: []string{"my-tag", "--privacy", "shared", "--description", "some notes"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "my-tag") + }, + }, + { + name: "name is trimmed before reaching the service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CreateTag(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.CreateParams) (apptags.CreateResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", params.Name) + return apptags.CreateResult{Meta: okMeta(), Tag: tag("my-tag")}, nil + }, + ) + return m + }, + args: []string{" my-tag "}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "my-tag") + }, + }, + { + name: "error - missing arg", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: nil, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "error - too many args", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"a", "b"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "error - service failure surfaced", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return( + apptags.CreateResult{}, + cenclierrors.NewCencliError(errors.New("tag already exists")), + ) + return m + }, + args: []string{"dupe"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag already exists") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stdout, stderr, err := runCreateCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/delete.go b/internal/command/tags/delete.go new file mode 100644 index 0000000..73fe741 --- /dev/null +++ b/internal/command/tags/delete.go @@ -0,0 +1,188 @@ +package tags + +import ( + "context" + "fmt" + "os" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" + "github.com/censys/cencli/internal/pkg/term" + "github.com/censys/cencli/internal/pkg/ui/form" +) + +const deleteCmdName = "delete" + +// DeleteCommand implements `tags delete `, removing a tag by name or UUID. +// It is the first destructive command and prompts for confirmation unless --yes +// is set; in a non-interactive terminal without --yes it refuses rather than +// deleting silently. +type DeleteCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags deleteCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + yes bool + // result stores the deletion outcome for rendering + result tags.DeleteResult + // seams - overridable in tests; defaulted in NewDeleteCommand + confirm func(ctx context.Context, message string) (bool, error) + stdinIsTTY func() bool +} + +type deleteCommandFlags struct { + orgID flags.OrgIDFlag + yes flags.BoolFlag +} + +// deletedTag is the data-mode payload for a successful deletion; there is no tag +// body returned by the endpoint, so only the identifier is echoed. +type deletedTag struct { + Tag string `json:"tag" yaml:"tag"` + Deleted bool `json:"deleted" yaml:"deleted"` +} + +var _ command.Command = (*DeleteCommand)(nil) + +func NewDeleteCommand(cmdContext *command.Context) *DeleteCommand { + return &DeleteCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + confirm: form.Confirm, + stdinIsTTY: func() bool { return term.IsTTY(os.Stdin) }, + } +} + +func (c *DeleteCommand) Use() string { + return fmt.Sprintf("%s ", deleteCmdName) +} + +func (c *DeleteCommand) Short() string { + return "Delete a tag" +} + +func (c *DeleteCommand) Long() string { + return `Delete a tag by its name or UUID. This cannot be undone. + +You are prompted to confirm before the tag is deleted. Use --yes to skip the prompt; in a non-interactive terminal --yes is required.` +} + +func (c *DeleteCommand) Examples() []string { + return []string{ + "my-tag # Delete a tag by name (prompts for confirmation)", + "my-tag --yes # Delete without confirming", + } +} + +func (c *DeleteCommand) Args() command.PositionalArgs { + return command.ExactArgs(1) +} + +func (c *DeleteCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *DeleteCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *DeleteCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.yes = flags.NewBoolFlag(c.Flags(), "yes", "y", false, "skip the confirmation prompt") + return nil +} + +func (c *DeleteCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + yes, err := c.flags.yes.Value() + if err != nil { + return err + } + c.yes = yes + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + + // Gate the confirmation before resolving the service so a non-interactive + // invocation without --yes fails with a clear confirmation error rather than + // deleting silently (and before any auth is required). + if !c.yes && !c.stdinIsTTY() { + return NewConfirmationRequiredError() + } + + return c.resolveTagsService() +} + +func (c *DeleteCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + "yes", c.yes, + ) + + if !c.yes { + message := fmt.Sprintf("Delete tag %q? This cannot be undone.", c.tagID.String()) + confirmed, err := confirmAction(cmd.Context(), c.confirm, message) + if err != nil { + return err + } + if !confirmed { + formatter.Println(formatter.Stderr, "Deletion aborted.") + return nil + } + } + + err := c.WithProgress( + cmd.Context(), + logger, + "Deleting tag...", + func(pctx context.Context) cenclierrors.CencliError { + var deleteErr cenclierrors.CencliError + c.result, deleteErr = c.tagsSvc.DeleteTag(pctx, tags.DeleteParams{ + OrgID: c.orgID, + TagID: c.tagID, + }) + return deleteErr + }, + ) + if err != nil { + logger.Debug("delete tag failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + return c.PrintData(c, deletedTag{Tag: c.result.TagID, Deleted: true}) +} + +func (c *DeleteCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// RenderShort renders a confirmation line for the deleted tag (TTY-aware). +func (c *DeleteCommand) RenderShort() cenclierrors.CencliError { + line := styles.GlobalStyles.Signature.Render(fmt.Sprintf("Tag %q deleted.", c.result.TagID)) + formatter.Println(formatter.Stdout, line) + return nil +} diff --git a/internal/command/tags/delete_test.go b/internal/command/tags/delete_test.go new file mode 100644 index 0000000..d93256a --- /dev/null +++ b/internal/command/tags/delete_test.go @@ -0,0 +1,202 @@ +package tags + +import ( + "bytes" + "context" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/ui/form" +) + +type deleteSeams struct { + confirm func(ctx context.Context, message string) (bool, error) + stdinIsTTY func() bool +} + +func runDeleteCommand(t *testing.T, svc apptags.Service, seams deleteSeams, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + cmd := NewDeleteCommand(cmdContext) + if seams.confirm != nil { + cmd.confirm = seams.confirm + } + if seams.stdinIsTTY != nil { + cmd.stdinIsTTY = seams.stdinIsTTY + } + rootCmd, buildErr := command.RootCommandToCobra(cmd) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +// deleteSuccessService returns a service that expects DeleteTag once and echoes +// the given identifier. +func deleteSuccessService(id string) func(ctrl *gomock.Controller) apptags.Service { + return func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().DeleteTag(gomock.Any(), gomock.Any()).Return( + apptags.DeleteResult{Meta: okMeta(), TagID: id}, + nil, + ) + return m + } +} + +// deleteNoCallService returns a service that must not be called. +func deleteNoCallService(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) +} + +func TestTagsDeleteCommand(t *testing.T) { + testCases := []struct { + name string + args []string + isTTY bool + confirmResult bool + confirmErr error + expectConfirm bool + service func(ctrl *gomock.Controller) apptags.Service + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "--yes skips the prompt and deletes", + args: []string{"alpha", "--yes"}, + isTTY: true, + expectConfirm: false, + service: deleteSuccessService("alpha"), + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "alpha") + require.Contains(t, stdout, "deleted") + }, + }, + { + name: "confirmation accepted deletes", + args: []string{"alpha"}, + isTTY: true, + confirmResult: true, + expectConfirm: true, + service: deleteSuccessService("alpha"), + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "deleted") + }, + }, + { + name: "confirmation declined aborts without deleting", + args: []string{"alpha"}, + isTTY: true, + confirmResult: false, + expectConfirm: true, + service: deleteNoCallService, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.NotContains(t, stdout, "deleted") + require.Contains(t, stderr, "aborted") + }, + }, + { + name: "non-interactive terminal without --yes errors", + args: []string{"alpha"}, + isTTY: false, + expectConfirm: false, + service: deleteNoCallService, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "confirmation required") + }, + }, + { + name: "user abort during prompt returns interrupted", + args: []string{"alpha"}, + isTTY: true, + confirmResult: false, + confirmErr: form.ErrUserAborted, + expectConfirm: true, + service: deleteNoCallService, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cancelled") + }, + }, + { + name: "json output renders the deletion payload", + args: []string{"alpha", "--yes", "--output-format", "json"}, + isTTY: true, + expectConfirm: false, + service: deleteSuccessService("alpha"), + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"tag": "alpha"`) + require.Contains(t, stdout, `"deleted": true`) + }, + }, + { + name: "missing arg errors", + args: nil, + isTTY: true, + expectConfirm: false, + service: deleteNoCallService, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "empty tag id errors before any prompt or service call", + args: []string{" "}, + isTTY: true, + expectConfirm: false, + service: deleteNoCallService, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + confirmCalled := false + seams := deleteSeams{ + confirm: func(_ context.Context, _ string) (bool, error) { + confirmCalled = true + return tc.confirmResult, tc.confirmErr + }, + stdinIsTTY: func() bool { return tc.isTTY }, + } + + stdout, stderr, err := runDeleteCommand(t, tc.service(ctrl), seams, tc.args) + require.Equal(t, tc.expectConfirm, confirmCalled, "confirm invocation mismatch") + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/errors.go b/internal/command/tags/errors.go new file mode 100644 index 0000000..40b4e68 --- /dev/null +++ b/internal/command/tags/errors.go @@ -0,0 +1,250 @@ +package tags + +import ( + "fmt" + "time" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +// Terminal operation statuses the command layer branches on for its exit code. +// They mirror the API's status enum; the service validates the full set. +const ( + statusSucceeded = "succeeded" + statusLimitReached = "limit_reached" + statusFailed = "failed" + statusCancelled = "cancelled" +) + +// timeoutWithoutWaitError signals that --timeout was set without --wait, where +// it would have no effect. +type timeoutWithoutWaitError struct{} + +func NewTimeoutWithoutWaitError() cenclierrors.CencliError { return &timeoutWithoutWaitError{} } + +func (e *timeoutWithoutWaitError) Error() string { + return "--timeout only applies while polling; add --wait or drop --timeout" +} + +func (e *timeoutWithoutWaitError) Title() string { return "Conflicting Flags" } + +func (e *timeoutWithoutWaitError) ShouldPrintUsage() bool { return true } + +// invalidWaitTimeoutError signals a negative --timeout, which would give up +// before the first poll ever ran. +type invalidWaitTimeoutError struct { + value time.Duration +} + +func NewInvalidWaitTimeoutError(value time.Duration) cenclierrors.CencliError { + return &invalidWaitTimeoutError{value: value} +} + +func (e *invalidWaitTimeoutError) Error() string { + return fmt.Sprintf("--timeout must not be negative (got %s); use 0 to wait without a time limit", e.value) +} + +func (e *invalidWaitTimeoutError) Title() string { return "Invalid Timeout" } + +func (e *invalidWaitTimeoutError) ShouldPrintUsage() bool { return true } + +// assignModeConflictError signals that explicit assets and --query were given +// together. Bulk is never inferred, so the two input modes cannot be mixed. +type assignModeConflictError struct{} + +func NewAssignModeConflictError() cenclierrors.CencliError { return &assignModeConflictError{} } + +func (e *assignModeConflictError) Error() string { + return "--query assigns by search results, so it cannot be combined with explicit assets or --input-file" +} + +func (e *assignModeConflictError) Title() string { return "Conflicting Input Modes" } + +func (e *assignModeConflictError) ShouldPrintUsage() bool { return true } + +// unassignModeConflictError signals that explicit assets and the bulk filters +// were given together. As with assign, bulk is never inferred, so the two input +// modes cannot be mixed. +type unassignModeConflictError struct{} + +func NewUnassignModeConflictError() cenclierrors.CencliError { return &unassignModeConflictError{} } + +func (e *unassignModeConflictError) Error() string { + return "--all and the time filters unassign by filter, so they cannot be combined with explicit assets or --input-file" +} + +func (e *unassignModeConflictError) Title() string { return "Conflicting Input Modes" } + +func (e *unassignModeConflictError) ShouldPrintUsage() bool { return true } + +// allWithTimeFilterError signals that --all was narrowed by a time filter. --all +// means every assignment, so the combination contradicts itself rather than +// meaning either one. +type allWithTimeFilterError struct{} + +func NewAllWithTimeFilterError() cenclierrors.CencliError { return &allWithTimeFilterError{} } + +func (e *allWithTimeFilterError) Error() string { + return "--all unassigns every assignment, so it cannot be combined with --created-before or --created-after" +} + +func (e *allWithTimeFilterError) Title() string { return "Conflicting Flags" } + +func (e *allWithTimeFilterError) ShouldPrintUsage() bool { return true } + +// flagRequiresBulkError signals that a bulk-only flag was set without the flag +// that selects bulk mode, where it would have no effect. verb and trigger name +// the operation and its mode flag, since assign and unassign enter bulk mode +// differently. +type flagRequiresBulkError struct { + flag string + verb string + trigger string +} + +// NewFlagRequiresQueryError reports a bulk-only assign flag used without --query. +func NewFlagRequiresQueryError(flag string) cenclierrors.CencliError { + return &flagRequiresBulkError{flag: flag, verb: "assignment", trigger: "query"} +} + +// NewFlagRequiresAllError reports a bulk-only unassign flag used without --all +// or a time filter. It names --all as the fix, being the unfiltered form. +func NewFlagRequiresAllError(flag string) cenclierrors.CencliError { + return &flagRequiresBulkError{flag: flag, verb: "unassignment", trigger: "all"} +} + +func (e *flagRequiresBulkError) Error() string { + return fmt.Sprintf("--%s only applies to a bulk %s; add --%s or drop --%s", + e.flag, e.verb, e.trigger, e.flag) +} + +func (e *flagRequiresBulkError) Title() string { return "Conflicting Flags" } + +func (e *flagRequiresBulkError) ShouldPrintUsage() bool { return true } + +// operationFailedError signals that a waited-on operation finished as failed. +// The operation itself was still rendered; this only drives the exit code. +type operationFailedError struct { + operation tags.TagOperation +} + +func NewOperationFailedError(op tags.TagOperation) cenclierrors.CencliError { + return &operationFailedError{operation: op} +} + +func (e *operationFailedError) Error() string { + msg := fmt.Sprintf("operation %s failed after %d of %d asset(s)", + e.operation.ID, e.operation.SuccessfulCount, e.operation.TotalCount) + if detail := operationDetail(e.operation); detail != "" { + msg = fmt.Sprintf("%s: %s", msg, detail) + } + return msg +} + +func (e *operationFailedError) Title() string { return "Operation Failed" } + +func (e *operationFailedError) ShouldPrintUsage() bool { return false } + +// operationCancelledError signals that a waited-on operation was cancelled. +// Work already committed before the cancellation is kept by the API. +type operationCancelledError struct { + operation tags.TagOperation +} + +func NewOperationCancelledError(op tags.TagOperation) cenclierrors.CencliError { + return &operationCancelledError{operation: op} +} + +func (e *operationCancelledError) Error() string { + msg := fmt.Sprintf("operation %s was cancelled after %d of %d asset(s)", + e.operation.ID, e.operation.SuccessfulCount, e.operation.TotalCount) + if detail := operationDetail(e.operation); detail != "" { + msg = fmt.Sprintf("%s: %s", msg, detail) + } + return msg +} + +func (e *operationCancelledError) Title() string { return "Operation Cancelled" } + +func (e *operationCancelledError) ShouldPrintUsage() bool { return false } + +// operationDetail picks the most specific explanation the API gave. On failure +// status_message mirrors error_message, so either one will do. +func operationDetail(op tags.TagOperation) string { + if op.ErrorMessage != nil && *op.ErrorMessage != "" { + return *op.ErrorMessage + } + if op.StatusMessage != nil && *op.StatusMessage != "" { + return *op.StatusMessage + } + return "" +} + +// nothingToUpdateError signals that `tags update` was invoked without any +// mutation flag, so there is nothing to change. +type nothingToUpdateError struct{} + +func NewNothingToUpdateError() cenclierrors.CencliError { return ¬hingToUpdateError{} } + +func (e *nothingToUpdateError) Error() string { + return "no fields to update; specify at least one of --name, --privacy, --description, --clear-description" +} + +func (e *nothingToUpdateError) Title() string { return "Nothing To Update" } + +func (e *nothingToUpdateError) ShouldPrintUsage() bool { return true } + +// descriptionConflictError signals that --description and --clear-description +// were used together, which is contradictory. +type descriptionConflictError struct{} + +func NewDescriptionConflictError() cenclierrors.CencliError { return &descriptionConflictError{} } + +func (e *descriptionConflictError) Error() string { + return "--description and --clear-description cannot be used together" +} + +func (e *descriptionConflictError) Title() string { return "Conflicting Flags" } + +func (e *descriptionConflictError) ShouldPrintUsage() bool { return true } + +// confirmationRequiredError signals that a destructive command was invoked in a +// non-interactive terminal without --yes, so it cannot prompt for confirmation. +type confirmationRequiredError struct{} + +func NewConfirmationRequiredError() cenclierrors.CencliError { return &confirmationRequiredError{} } + +func (e *confirmationRequiredError) Error() string { + return "confirmation required; re-run with --yes to skip the prompt in a non-interactive terminal" +} + +func (e *confirmationRequiredError) Title() string { return "Confirmation Required" } + +func (e *confirmationRequiredError) ShouldPrintUsage() bool { return true } + +// allAssetsFailedError signals that no asset in an explicit assign or unassign +// succeeded. The per-asset outcomes were still rendered; this only drives the +// exit code, the way operationFailedError does after a wait. It counts failures +// against assets requested rather than saying "all", since an interrupted run +// records fewer failures than it was given assets. +type allAssetsFailedError struct { + failed int + requested int + // verb is the past participle: "assigned" or "unassigned". + verb string +} + +// NewAllAssetsFailedError reports that no asset in the run succeeded. +func NewAllAssetsFailedError(failed, requested int, verb string) cenclierrors.CencliError { + return &allAssetsFailedError{failed: failed, requested: requested, verb: verb} +} + +func (e *allAssetsFailedError) Error() string { + return fmt.Sprintf("no asset was %s; %d of %d failed - see the per-asset results above", + e.verb, e.failed, e.requested) +} + +func (e *allAssetsFailedError) Title() string { return "All Assets Failed" } + +func (e *allAssetsFailedError) ShouldPrintUsage() bool { return false } diff --git a/internal/command/tags/get.go b/internal/command/tags/get.go new file mode 100644 index 0000000..fa4d0b8 --- /dev/null +++ b/internal/command/tags/get.go @@ -0,0 +1,150 @@ +package tags + +import ( + "context" + "fmt" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" +) + +const getCmdName = "get" + +// GetCommand implements `tags get `, retrieving a single tag by name or UUID. +type GetCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags getCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + // result stores the fetched tag for rendering + result tags.GetResult +} + +type getCommandFlags struct { + orgID flags.OrgIDFlag +} + +var _ command.Command = (*GetCommand)(nil) + +func NewGetCommand(cmdContext *command.Context) *GetCommand { + return &GetCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *GetCommand) Use() string { + return fmt.Sprintf("%s ", getCmdName) +} + +func (c *GetCommand) Short() string { + return "Retrieve a single tag by name or ID" +} + +func (c *GetCommand) Long() string { + return `Retrieve a single tag by its name or UUID. + +Tag names are unique within an organization, so a name and its ID can be used interchangeably. + +The tag payload carries no assignment count, so counting the assets it is assigned to costs a second request. That count is always reported; if only the count fails, the tag is still printed and the count error follows it.` +} + +func (c *GetCommand) Examples() []string { + return []string{ + "my-tag # Get a tag by name", + " # Get a tag by UUID", + "my-tag --output-format json # Output as JSON", + } +} + +func (c *GetCommand) Args() command.PositionalArgs { + return command.ExactArgs(1) +} + +func (c *GetCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *GetCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *GetCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + return nil +} + +func (c *GetCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + return c.resolveTagsService() +} + +func (c *GetCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + ) + + err := c.WithProgress( + cmd.Context(), + logger, + "Fetching tag...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.tagsSvc.GetTag(pctx, tags.GetParams{ + OrgID: c.orgID, + TagID: c.tagID, + }) + return fetchErr + }, + ) + if err != nil { + logger.Debug("get tag failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.result.Tag); renderErr != nil { + return renderErr + } + + // The tag was fetched; only the asset count failed. + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) + } + + return nil +} + +func (c *GetCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// RenderShort renders the fetched tag as a labeled detail view (TTY-aware). +func (c *GetCommand) RenderShort() cenclierrors.CencliError { + return renderTagDetail("━━━ Tag ━━━", c.result.Tag) +} diff --git a/internal/command/tags/get_test.go b/internal/command/tags/get_test.go new file mode 100644 index 0000000..68cbf8c --- /dev/null +++ b/internal/command/tags/get_test.go @@ -0,0 +1,236 @@ +package tags + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +func runGetCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewGetCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func TestTagsGetCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Return( + apptags.GetResult{Meta: okMeta(), Tag: tag("alpha")}, + nil, + ) + return m + }, + args: []string{"alpha"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "alpha") + require.Contains(t, stdout, "Name:") + require.Contains(t, stdout, "Privacy:") + }, + }, + { + name: "success - json output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Return( + apptags.GetResult{Meta: okMeta(), Tag: tag("alpha")}, + nil, + ) + return m + }, + args: []string{"alpha", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"name": "alpha"`) + }, + }, + { + name: "tag identifier threaded to service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.GetParams) (apptags.GetResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", params.TagID.String()) + require.True(t, params.TagID.UID().IsAbsent()) + return apptags.GetResult{Meta: okMeta(), Tag: tag("my-tag")}, nil + }, + ) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "my-tag") + }, + }, + { + name: "error - missing arg", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: nil, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "error - too many args", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"a", "b"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "error - empty tag id", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{" "}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + }, + }, + { + name: "error - service failure surfaced", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Return( + apptags.GetResult{}, + cenclierrors.NewCencliError(errors.New("tag not found")), + ) + return m + }, + args: []string{"missing"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag not found") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stdout, stderr, err := runGetCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} + +func TestTagsGetCommand_AssetCount(t *testing.T) { + t.Run("count is rendered without asking for it", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + count := int64(7) + counted := tag("alpha") + counted.AssetCount = &count + + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Return( + apptags.GetResult{Meta: okMeta(), Tag: counted}, nil) + + stdout, _, err := runGetCommand(t, m, []string{"alpha"}) + require.NoError(t, err) + require.Contains(t, stdout, "Assets:") + require.Contains(t, stdout, "7") + }) + + t.Run("the count is serialized in json", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + count := int64(0) + counted := tag("alpha") + counted.AssetCount = &count + + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Return( + apptags.GetResult{Meta: okMeta(), Tag: counted}, nil) + + // A zero count must survive omitempty as an explicit 0, or a script + // cannot tell "no assets" from "not counted". + stdout, _, err := runGetCommand(t, m, []string{"alpha", "--output-format", "json"}) + require.NoError(t, err) + require.Contains(t, stdout, `"asset_count": 0`) + }) + + t.Run("--asset-count is no longer a flag", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Times(0) + + _, _, err := runGetCommand(t, m, []string{"alpha", "--asset-count"}) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown flag") + }) + + t.Run("count failure still renders the tag and reports the error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetTag(gomock.Any(), gomock.Any()).Return( + apptags.GetResult{ + Meta: okMeta(), + Tag: tag("alpha"), + PartialError: cenclierrors.NewCencliError(errors.New("permission denied")), + }, nil) + + // A failed count must not fail the command: the tag is still on stdout + // and the exit code stays 0. + stdout, stderr, err := runGetCommand(t, m, []string{"alpha"}) + require.NoError(t, err) + require.Contains(t, stdout, "alpha") + require.NotContains(t, stdout, "Assets:") + require.Contains(t, stderr, "permission denied") + }) +} diff --git a/internal/command/tags/list.go b/internal/command/tags/list.go new file mode 100644 index 0000000..1521b4d --- /dev/null +++ b/internal/command/tags/list.go @@ -0,0 +1,240 @@ +package tags + +import ( + "context" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" +) + +const ( + listCmdName = "list" + + defaultPageSize = 100 + minPageSize = 1 + // maxPageSize is the largest page the tags endpoints accept; rejecting more + // here saves a request that could only come back as a 422. + maxPageSize = 1000 + defaultMaxPages = 1 +) + +// ListCommand implements `tags list`, listing an organization's tags. +type ListCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags listCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + privacy mo.Option[string] + name mo.Option[string] + createdBy mo.Option[string] + orderBy mo.Option[string] + pageSize mo.Option[uint64] + maxPages mo.Option[uint64] + // result stores the list result for rendering + result tags.ListResult +} + +type listCommandFlags struct { + orgID flags.OrgIDFlag + privacy flags.StringFlag + name flags.StringFlag + createdBy flags.UUIDFlag + orderBy flags.StringFlag + pageSize flags.IntegerFlag + maxPages flags.IntegerFlag +} + +var _ command.Command = (*ListCommand)(nil) + +func NewListCommand(cmdContext *command.Context) *ListCommand { + return &ListCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *ListCommand) Use() string { + return listCmdName +} + +func (c *ListCommand) Short() string { + return "List all tags" +} + +func (c *ListCommand) Long() string { + return `List all tags in your organization. + +Results can be filtered by privacy, name, and creator, and sorted by various fields.` +} + +func (c *ListCommand) Examples() []string { + return []string{ + "# List all tags", + "--privacy shared # List only shared tags", + "--name my-tag # Filter by exact name", + "--order-by name_desc # Sort by name descending", + "--output-format json # Output as JSON", + } +} + +func (c *ListCommand) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *ListCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *ListCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *ListCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.privacy = flags.NewStringFlag(c.Flags(), false, "privacy", "", "", "filter by privacy (private, shared)") + c.flags.name = flags.NewStringFlag(c.Flags(), false, "name", "", "", "filter by exact tag name") + c.flags.createdBy = flags.NewUUIDFlag(c.Flags(), false, "created-by", "", mo.None[uuid.UUID](), + "filter by the UUID of the tag's creator") + c.flags.orderBy = flags.NewStringFlag(c.Flags(), false, "order-by", "", "", "sort order (name_asc, name_desc, created_at_asc, created_at_desc, updated_at_asc, updated_at_desc)") + c.flags.pageSize = flags.NewIntegerFlag( + c.Flags(), + false, + "page-size", + "n", + mo.Some[int64](defaultPageSize), + "number of tags to return per page", + mo.Some[int64](minPageSize), + mo.Some[int64](maxPageSize), + ) + c.flags.maxPages = flags.NewIntegerFlag( + c.Flags(), + false, + "max-pages", + "p", + mo.Some[int64](defaultMaxPages), + "maximum number of pages to fetch (-1 for all pages)", + mo.None[int64](), // allow custom validation in PreRun (to support -1) + mo.None[int64](), // no maximum + ) + return nil +} + +func (c *ListCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + if err := c.parseOrgIDFlag(); err != nil { + return err + } + if err := c.parseFilterFlags(); err != nil { + return err + } + if err := c.parsePaginationFlags(); err != nil { + return err + } + return c.resolveTagsService() +} + +func (c *ListCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "privacy_set", c.privacy.IsPresent(), + "name_set", c.name.IsPresent(), + "pageSize_set", c.pageSize.IsPresent(), + "maxPages_set", c.maxPages.IsPresent(), + ) + + warnFetchingAllPages(c.Config().Quiet, logger, c.maxPages) + + err := c.WithProgress( + cmd.Context(), + logger, + "Fetching tags...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.tagsSvc.ListTags(pctx, tags.ListParams{ + OrgID: c.orgID, + Privacy: c.privacy, + Name: c.name, + CreatedBy: c.createdBy, + OrderBy: c.orderBy, + PageSize: c.pageSize, + MaxPages: c.maxPages, + }) + return fetchErr + }, + ) + if err != nil { + logger.Debug("list tags failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.result.Tags); renderErr != nil { + return renderErr + } + + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) + } + + return nil +} + +func (c *ListCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +func (c *ListCommand) parseOrgIDFlag() cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + return err +} + +// parseFilterFlags reads the optional string filters; a blank value omits the filter. +func (c *ListCommand) parseFilterFlags() cenclierrors.CencliError { + privacy, err := c.flags.privacy.Value() + if err != nil { + return err + } + c.privacy = optionalNonEmpty(privacy) + + name, err := c.flags.name.Value() + if err != nil { + return err + } + c.name = optionalNonEmpty(name) + + createdBy, err := c.flags.createdBy.Value() + if err != nil { + return err + } + c.createdBy = uuidFilterString(createdBy) + + orderBy, err := c.flags.orderBy.Value() + if err != nil { + return err + } + c.orderBy = optionalNonEmpty(orderBy) + + return nil +} + +func (c *ListCommand) parsePaginationFlags() cenclierrors.CencliError { + var err cenclierrors.CencliError + c.pageSize, c.maxPages, err = parsePaginationFlags(c.flags.pageSize, c.flags.maxPages) + return err +} diff --git a/internal/command/tags/operations.go b/internal/command/tags/operations.go new file mode 100644 index 0000000..b420c4d --- /dev/null +++ b/internal/command/tags/operations.go @@ -0,0 +1,69 @@ +package tags + +import ( + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +const operationsCmdName = "operations" + +// OperationsCommand is the parent for the tag-operations family, which tracks +// the asynchronous jobs created by bulk assign and unassign. +type OperationsCommand struct { + *command.BaseCommand +} + +var _ command.Command = (*OperationsCommand)(nil) + +// NewOperationsCommand creates the operations parent command with its subcommands. +func NewOperationsCommand(cmdContext *command.Context) *OperationsCommand { + return &OperationsCommand{BaseCommand: command.NewBaseCommand(cmdContext)} +} + +func (c *OperationsCommand) Use() string { + return operationsCmdName +} + +func (c *OperationsCommand) Short() string { + return "Track the asynchronous jobs created by bulk tag operations" +} + +func (c *OperationsCommand) Long() string { + return `Track the asynchronous jobs created by bulk tag operations. + +Bulk assign and unassign submit a job rather than acting immediately; these commands list those jobs, inspect a single one, and cancel one that is still running.` +} + +func (c *OperationsCommand) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *OperationsCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *OperationsCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + +func (c *OperationsCommand) Init() error { + return c.AddSubCommands( + NewOperationsListCommand(c.Context), + NewOperationsGetCommand(c.Context), + NewOperationsCancelCommand(c.Context), + ) +} + +func (c *OperationsCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + return nil +} + +func (c *OperationsCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + // Parent command shows help when run without subcommands. + if err := cmd.Help(); err != nil { + return cenclierrors.NewCencliError(err) + } + return nil +} diff --git a/internal/command/tags/operations_cancel.go b/internal/command/tags/operations_cancel.go new file mode 100644 index 0000000..02081bc --- /dev/null +++ b/internal/command/tags/operations_cancel.go @@ -0,0 +1,149 @@ +package tags + +import ( + "context" + "fmt" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const operationsCancelCmdName = "cancel" + +// OperationsCancelCommand implements `tags operations cancel `, +// stopping a running bulk job. It does not confirm: cancelling only stops +// further processing, and the destructive step was the job it is stopping. +type OperationsCancelCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags operationsCancelCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + operationID string + // result stores the cancelled operation for rendering + result tags.CancelOperationResult +} + +type operationsCancelCommandFlags struct { + orgID flags.OrgIDFlag +} + +var _ command.Command = (*OperationsCancelCommand)(nil) + +func NewOperationsCancelCommand(cmdContext *command.Context) *OperationsCancelCommand { + return &OperationsCancelCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *OperationsCancelCommand) Use() string { + return fmt.Sprintf("%s ", operationsCancelCmdName) +} + +func (c *OperationsCancelCommand) Short() string { + return "Cancel a running bulk tag operation" +} + +func (c *OperationsCancelCommand) Long() string { + return `Cancel a running bulk tag operation, identified by the tag it belongs to, given by name or UUID, and the operation's UUID. + +Cancelling stops the job from processing any more assets; it does not undo the assignments it has already made or removed. An operation that has already finished cannot be cancelled.` +} + +func (c *OperationsCancelCommand) Examples() []string { + return []string{ + "my-tag # Stop a running bulk job", + } +} + +func (c *OperationsCancelCommand) Args() command.PositionalArgs { + return command.ExactArgs(2) +} + +func (c *OperationsCancelCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *OperationsCancelCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *OperationsCancelCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + return nil +} + +func (c *OperationsCancelCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + c.operationID, err = requireOperationID(args[1]) + if err != nil { + return err + } + + return c.resolveTagsService() +} + +func (c *OperationsCancelCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + ) + + err := c.WithProgress( + cmd.Context(), + logger, + "Cancelling operation...", + func(pctx context.Context) cenclierrors.CencliError { + var cancelErr cenclierrors.CencliError + c.result, cancelErr = c.tagsSvc.CancelOperation(pctx, tags.CancelOperationParams{ + OrgID: c.orgID, + TagID: c.tagID, + OperationID: c.operationID, + }) + return cancelErr + }, + ) + if err != nil { + logger.Debug("cancel operation failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + // A cancelled status is the point of this command, so it never drives the exit + // code the way it does after a --wait: succeeding here means exiting 0. + return c.PrintData(c, c.result.Operation) +} + +func (c *OperationsCancelCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// RenderShort renders the cancelled operation as a labeled detail view, the same +// way `operations get` shows it. +func (c *OperationsCancelCommand) RenderShort() cenclierrors.CencliError { + return renderOperationDetail(c.result.Operation) +} diff --git a/internal/command/tags/operations_cancel_test.go b/internal/command/tags/operations_cancel_test.go new file mode 100644 index 0000000..7f0a33a --- /dev/null +++ b/internal/command/tags/operations_cancel_test.go @@ -0,0 +1,240 @@ +package tags + +import ( + "bytes" + "context" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +func runOperationsCancelCommand( + t *testing.T, + svc apptags.Service, + args []string, +) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + cmd := NewOperationsCancelCommand(cmdContext) + rootCmd, buildErr := command.RootCommandToCobra(cmd) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +// cancelled builds the service result for an accepted cancellation. +func cancelled(status string) apptags.CancelOperationResult { + return apptags.CancelOperationResult{Meta: okMeta(), Operation: deleteOperation(status)} +} + +// cancelNoCallService asserts the cancel never reaches the service, proving the +// input was rejected (or the prompt declined) at the command boundary. +func cancelNoCallService(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).Times(0) + return m +} + +func TestTagsOperationsCancelCommand(t *testing.T) { + testCases := []struct { + name string + args []string + service func(t *testing.T, ctrl *gomock.Controller) apptags.Service + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "cancels and renders the operation", + args: []string{"my-tag", testOperationID}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.CancelOperationParams) (apptags.CancelOperationResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", p.TagID.String()) + require.Equal(t, testOperationID, p.OperationID) + return cancelled("cancelled"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + // A cancelled status is the goal here, so it must not become an error. + require.NoError(t, err) + require.Contains(t, stdout, "Tag Operation") + require.Contains(t, stdout, testOperationID) + require.Contains(t, stdout, "cancelled") + }, + }, + { + // The API answers with the operation as it stood when the request was + // accepted, so a job still winding down is a success, not a failure. + name: "a still-running operation after cancelling still exits zero", + args: []string{"my-tag", testOperationID}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).Return(cancelled("running"), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "running") + }, + }, + { + // Unlike a --wait, reading back a failed job here is not this command's + // verdict: the cancellation request itself succeeded. + name: "a failed operation after cancelling still exits zero", + args: []string{"my-tag", testOperationID}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).Return(cancelled("failed"), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "failed") + }, + }, + { + // Cancelling only stops further processing, so it runs unprompted - + // including with nothing on stdin, where a prompt could not be answered. + name: "cancels without prompting", + args: []string{"my-tag", testOperationID}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).Return(cancelled("cancelled"), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "cancelled") + }, + }, + { + // --yes belongs to the flows that still confirm; keeping a dead flag + // here would imply this one prompts. + name: "--yes is not a flag on cancel", + args: []string{"my-tag", testOperationID, "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return cancelNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "unknown flag") + }, + }, + { + name: "non-UUID operation ID is rejected before the service", + args: []string{"my-tag", "not-a-uuid"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return cancelNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "not-a-uuid") + }, + }, + { + name: "empty tag is rejected before the service", + args: []string{" ", testOperationID}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return cancelNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag name or ID is required") + }, + }, + { + name: "missing the operation argument is rejected", + args: []string{"my-tag"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return cancelNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "org id is threaded to the service", + args: []string{ + "my-tag", testOperationID, + "--org-id", "11111111-1111-1111-1111-111111111111", + }, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.CancelOperationParams) (apptags.CancelOperationResult, cenclierrors.CencliError) { + require.True(t, p.OrgID.IsPresent()) + return cancelled("cancelled"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "json output renders the operation payload", + args: []string{"my-tag", testOperationID, "--output-format", "json"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).Return(cancelled("cancelled"), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"status": "cancelled"`) + require.Contains(t, stdout, `"type": "bulk_delete"`) + }, + }, + { + // A finished job cannot be cancelled; that error must surface. + name: "service error is returned", + args: []string{"my-tag", testOperationID}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().CancelOperation(gomock.Any(), gomock.Any()).Return( + apptags.CancelOperationResult{}, cenclierrors.NewCencliError(errBoom)) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runOperationsCancelCommand(t, tc.service(t, ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/operations_get.go b/internal/command/tags/operations_get.go new file mode 100644 index 0000000..7f0e0c1 --- /dev/null +++ b/internal/command/tags/operations_get.go @@ -0,0 +1,181 @@ +package tags + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const operationsGetCmdName = "get" + +// OperationsGetCommand implements `tags operations get `, +// retrieving one bulk job and optionally polling it until it finishes. +type OperationsGetCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags operationsGetCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + operationID string + wait bool + timeout mo.Option[time.Duration] + // result stores the operation for rendering + result tags.GetOperationResult +} + +type operationsGetCommandFlags struct { + orgID flags.OrgIDFlag + wait flags.BoolFlag + timeout flags.HumanDurationFlag +} + +var _ command.Command = (*OperationsGetCommand)(nil) + +func NewOperationsGetCommand(cmdContext *command.Context) *OperationsGetCommand { + return &OperationsGetCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *OperationsGetCommand) Use() string { + return fmt.Sprintf("%s ", operationsGetCmdName) +} + +func (c *OperationsGetCommand) Short() string { + return "Retrieve a single bulk tag operation" +} + +func (c *OperationsGetCommand) Long() string { + return `Retrieve a single bulk tag operation by the tag it belongs to, given by name or UUID, and the operation's UUID. + +Use --wait to poll until the operation finishes. Waiting exits non-zero if the operation ends up failed or cancelled; without --wait the command simply reports the current status and exits 0.` +} + +func (c *OperationsGetCommand) Examples() []string { + return []string{ + "my-tag # Show an operation's current status", + "my-tag --wait # Poll until the operation finishes", + "my-tag --wait --timeout 5m # Give up waiting after 5 minutes", + } +} + +func (c *OperationsGetCommand) Args() command.PositionalArgs { + return command.ExactArgs(2) +} + +func (c *OperationsGetCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *OperationsGetCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *OperationsGetCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.wait = flags.NewBoolFlag(c.Flags(), "wait", "w", false, "poll until the operation reaches a final status") + c.flags.timeout = flags.NewHumanDurationFlag(c.Flags(), false, "timeout", "", + mo.Some(defaultWaitTimeout), "how long to wait before giving up (requires --wait) - use 0 for no limit") + return nil +} + +func (c *OperationsGetCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + c.operationID, err = requireOperationID(args[1]) + if err != nil { + return err + } + + c.wait, c.timeout, err = parseWaitFlags(cmd, c.flags.wait, c.flags.timeout) + if err != nil { + return err + } + + return c.resolveTagsService() +} + +func (c *OperationsGetCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + "wait", c.wait, + "timeout_set", c.timeout.IsPresent(), + ) + + if err := c.fetch(cmd.Context(), logger); err != nil { + if c.wait && cenclierrors.IsInterrupted(err) { + printOperationStillRunningNote(c.Config().Quiet, c.tagID.String(), c.operationID) + } + logger.Debug("get operation failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.result.Operation); renderErr != nil { + return renderErr + } + + // Only a wait reports the outcome through the exit code: a plain read of a + // failed operation is itself a successful read. + if !c.wait { + return nil + } + return reportOperationTerminalStatus(c.result.Operation) +} + +// fetch retrieves the operation once, or polls it when --wait is set. +func (c *OperationsGetCommand) fetch(ctx context.Context, logger *slog.Logger) cenclierrors.CencliError { + if !c.wait { + return c.WithProgress(ctx, logger, "Fetching operation...", + func(pctx context.Context) cenclierrors.CencliError { + var getErr cenclierrors.CencliError + c.result, getErr = c.tagsSvc.GetOperation(pctx, tags.GetOperationParams{ + OrgID: c.orgID, + TagID: c.tagID, + OperationID: c.operationID, + }) + return getErr + }) + } + + result, err := waitForOperation(ctx, c.BaseCommand, logger, c.tagsSvc, tags.WaitParams{ + OrgID: c.orgID, + TagID: c.tagID, + OperationID: c.operationID, + Timeout: c.timeout, + }) + c.result = result + return err +} + +func (c *OperationsGetCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} diff --git a/internal/command/tags/operations_get_test.go b/internal/command/tags/operations_get_test.go new file mode 100644 index 0000000..f1c181b --- /dev/null +++ b/internal/command/tags/operations_get_test.go @@ -0,0 +1,313 @@ +package tags + +import ( + "bytes" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +const testOperationID = "d421a231-eb5e-4927-a0be-8aa749eb731c" + +func runOperationsGetCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewOperationsGetCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func finishedOperation(status string) apptags.TagOperation { + ended := time.Unix(60, 0).UTC() + op := operation(testOperationID, status) + op.EndedAt = &ended + op.ProcessedCount = 100 + op.SuccessfulCount = 90 + return op +} + +// getOnly expects a plain read and no polling. +func getOnly(ctrl *gomock.Controller, op apptags.TagOperation) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{Meta: okMeta(), Operation: op}, nil) + return m +} + +// waitOnly expects polling and no plain read. +func waitOnly(ctrl *gomock.Controller, op apptags.TagOperation) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{Meta: okMeta(), Operation: op}, nil) + return m +} + +func TestTagsOperationsGetCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short detail view", + service: func(ctrl *gomock.Controller) apptags.Service { + return getOnly(ctrl, operation(testOperationID, "running")) + }, + args: []string{"my-tag", testOperationID}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Tag Operation") + require.Contains(t, stdout, testOperationID) + require.Contains(t, stdout, "running") + require.Contains(t, stdout, "40/100") + }, + }, + { + name: "json output renders the operation", + service: func(ctrl *gomock.Controller) apptags.Service { + return getOnly(ctrl, operation(testOperationID, "succeeded")) + }, + args: []string{"my-tag", testOperationID, "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"status": "succeeded"`) + }, + }, + { + name: "without --wait a failed operation is still a successful read", + service: func(ctrl *gomock.Controller) apptags.Service { + return getOnly(ctrl, finishedOperation("failed")) + }, + args: []string{"my-tag", testOperationID}, + assert: func(t *testing.T, stdout, stderr string, err error) { + // Reading the status is not the same as suffering the failure. + require.NoError(t, err) + require.Contains(t, stdout, "failed") + }, + }, + { + name: "--wait on a succeeded operation exits cleanly", + service: func(ctrl *gomock.Controller) apptags.Service { + return waitOnly(ctrl, finishedOperation("succeeded")) + }, + args: []string{"my-tag", testOperationID, "--wait"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "succeeded") + }, + }, + { + name: "--wait on a failed operation renders it then errors", + service: func(ctrl *gomock.Controller) apptags.Service { + op := finishedOperation("failed") + msg := "quota exhausted" + op.ErrorMessage = &msg + return waitOnly(ctrl, op) + }, + args: []string{"my-tag", testOperationID, "--wait"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + // The payload is still rendered; only the exit code carries the outcome. + require.Contains(t, stdout, testOperationID) + require.Contains(t, err.Error(), "quota exhausted") + }, + }, + { + name: "--wait on a cancelled operation errors", + service: func(ctrl *gomock.Controller) apptags.Service { + return waitOnly(ctrl, finishedOperation("cancelled")) + }, + args: []string{"my-tag", testOperationID, "--wait"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cancelled") + }, + }, + { + name: "--wait on a limit_reached operation warns but exits cleanly", + service: func(ctrl *gomock.Controller) apptags.Service { + op := finishedOperation("limit_reached") + msg := "plan asset limit reached" + op.StatusMessage = &msg + return waitOnly(ctrl, op) + }, + args: []string{"my-tag", testOperationID, "--wait"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + // A capped run still did real work, so it is not a failure. + require.NoError(t, err) + require.Contains(t, stderr, "asset limit") + require.Contains(t, stderr, "plan asset limit reached") + }, + }, + { + name: "interrupted wait explains the job keeps running", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{}, cenclierrors.NewInterruptedError()) + return m + }, + args: []string{"my-tag", testOperationID, "--wait"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stderr, "continues server-side") + require.Contains(t, stderr, "Track with: censys tags operations get my-tag "+testOperationID) + }, + }, + { + name: "--timeout is passed through when waiting", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, params apptags.WaitParams) (apptags.GetOperationResult, cenclierrors.CencliError) { + require.True(t, params.Timeout.IsPresent()) + require.Equal(t, 5*time.Minute, params.Timeout.MustGet()) + require.Equal(t, testOperationID, params.OperationID) + return apptags.GetOperationResult{Meta: okMeta(), Operation: finishedOperation("succeeded")}, nil + }) + return m + }, + args: []string{"my-tag", testOperationID, "--wait", "--timeout", "5m"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "--timeout 0 waits without a limit", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, params apptags.WaitParams) (apptags.GetOperationResult, cenclierrors.CencliError) { + // Zero means unbounded, matching the global --timeout-http. + require.False(t, params.Timeout.IsPresent()) + return apptags.GetOperationResult{Meta: okMeta(), Operation: finishedOperation("succeeded")}, nil + }) + return m + }, + args: []string{"my-tag", testOperationID, "--wait", "--timeout", "0"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "a negative --timeout is rejected before the service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + return m + }, + args: []string{"my-tag", testOperationID, "--wait", "--timeout", "-5m"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--timeout must not be negative") + }, + }, + { + name: "--timeout without --wait is rejected before the service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + return m + }, + args: []string{"my-tag", testOperationID, "--timeout", "5m"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--timeout only applies while polling") + }, + }, + { + name: "non-UUID operation ID is rejected before the service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Times(0) + return m + }, + args: []string{"my-tag", "not-a-uuid"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "not-a-uuid") + }, + }, + { + name: "empty tag is rejected before the service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Times(0) + return m + }, + args: []string{" ", testOperationID}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag name or ID is required") + }, + }, + { + name: "missing the operation argument is rejected", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Times(0) + return m + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "service error is returned", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().GetOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{}, cenclierrors.NewCencliError(errBoom)) + return m + }, + args: []string{"my-tag", testOperationID}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runOperationsGetCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/operations_list.go b/internal/command/tags/operations_list.go new file mode 100644 index 0000000..8aebd38 --- /dev/null +++ b/internal/command/tags/operations_list.go @@ -0,0 +1,232 @@ +package tags + +import ( + "context" + "fmt" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" +) + +const operationsListCmdName = "list" + +// OperationsListCommand implements `tags operations list []`, listing the +// asynchronous bulk jobs for one tag or for the whole organization. +type OperationsListCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags operationsListCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID mo.Option[identifiers.TagID] + opType mo.Option[string] + status mo.Option[string] + orderBy mo.Option[string] + pageSize mo.Option[uint64] + maxPages mo.Option[uint64] + // result stores the operations for rendering + result tags.OperationsResult +} + +type operationsListCommandFlags struct { + orgID flags.OrgIDFlag + opType flags.StringFlag + status flags.StringFlag + orderBy flags.StringFlag + pageSize flags.IntegerFlag + maxPages flags.IntegerFlag +} + +var _ command.Command = (*OperationsListCommand)(nil) + +func NewOperationsListCommand(cmdContext *command.Context) *OperationsListCommand { + return &OperationsListCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *OperationsListCommand) Use() string { + return fmt.Sprintf("%s []", operationsListCmdName) +} + +func (c *OperationsListCommand) Short() string { + return "List bulk tag operations" +} + +func (c *OperationsListCommand) Long() string { + return `List the asynchronous jobs created by bulk tag operations. + +Given a tag, by its name or UUID, only that tag's operations are listed; omit it to list operations across every tag in the organization.` +} + +func (c *OperationsListCommand) Examples() []string { + return []string{ + "# List operations across every tag", + "my-tag # List one tag's operations", + "my-tag --status running # Only operations still in flight", + "--type bulk_delete # Only bulk unassign jobs", + "--max-pages -1 # Fetch every page", + } +} + +func (c *OperationsListCommand) Args() command.PositionalArgs { + return command.RangeArgs(0, 1) +} + +func (c *OperationsListCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *OperationsListCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *OperationsListCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.opType = flags.NewStringFlag(c.Flags(), false, "type", "", "", "filter by operation type (bulk_create, bulk_delete)") + c.flags.status = flags.NewStringFlag(c.Flags(), false, "status", "", "", + "filter by status (pending, running, succeeded, limit_reached, failed, cancelled)") + c.flags.orderBy = flags.NewStringFlag(c.Flags(), false, "order-by", "", "", "sort order (create_time_asc, create_time_desc)") + c.flags.pageSize = flags.NewIntegerFlag( + c.Flags(), + false, + "page-size", + "n", + mo.Some[int64](defaultPageSize), + "number of operations to return per page", + mo.Some[int64](minPageSize), + mo.Some[int64](maxPageSize), + ) + c.flags.maxPages = flags.NewIntegerFlag( + c.Flags(), + false, + "max-pages", + "p", + mo.Some[int64](defaultMaxPages), + "maximum number of pages to fetch (-1 for all pages)", + mo.None[int64](), // allow custom validation in PreRun (to support -1) + mo.None[int64](), // no maximum + ) + return nil +} + +func (c *OperationsListCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + // The tag is optional here; without it the service lists org-wide. + if len(args) == 1 { + tagID, tagErr := requireTagID(args[0]) + if tagErr != nil { + return tagErr + } + c.tagID = mo.Some(tagID) + } + + if err := c.parseFilterFlags(); err != nil { + return err + } + if err := c.parsePaginationFlags(); err != nil { + return err + } + + return c.resolveTagsService() +} + +func (c *OperationsListCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_set", c.tagID.IsPresent(), + "type_set", c.opType.IsPresent(), + "status_set", c.status.IsPresent(), + "pageSize_set", c.pageSize.IsPresent(), + "maxPages_set", c.maxPages.IsPresent(), + ) + + warnFetchingAllPages(c.Config().Quiet, logger, c.maxPages) + + err := c.WithProgress( + cmd.Context(), + logger, + "Fetching operations...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.tagsSvc.ListOperations(pctx, tags.OperationsParams{ + OrgID: c.orgID, + TagID: c.tagID, + Type: c.opType, + Status: c.status, + OrderBy: c.orderBy, + PageSize: c.pageSize, + MaxPages: c.maxPages, + }) + return fetchErr + }, + ) + if err != nil { + logger.Debug("list operations failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.result.Operations); renderErr != nil { + return renderErr + } + + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) + } + + return nil +} + +func (c *OperationsListCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// parseFilterFlags reads the optional filters; a blank value omits the filter. +func (c *OperationsListCommand) parseFilterFlags() cenclierrors.CencliError { + opType, err := c.flags.opType.Value() + if err != nil { + return err + } + c.opType = optionalNonEmpty(opType) + + status, err := c.flags.status.Value() + if err != nil { + return err + } + c.status = optionalNonEmpty(status) + + orderBy, err := c.flags.orderBy.Value() + if err != nil { + return err + } + c.orderBy = optionalNonEmpty(orderBy) + + return nil +} + +func (c *OperationsListCommand) parsePaginationFlags() cenclierrors.CencliError { + var err cenclierrors.CencliError + c.pageSize, c.maxPages, err = parsePaginationFlags(c.flags.pageSize, c.flags.maxPages) + return err +} diff --git a/internal/command/tags/operations_list_test.go b/internal/command/tags/operations_list_test.go new file mode 100644 index 0000000..286d4df --- /dev/null +++ b/internal/command/tags/operations_list_test.go @@ -0,0 +1,285 @@ +package tags + +import ( + "bytes" + "errors" + "testing" + "time" + + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +// errBoom stands in for an arbitrary failure surfacing from the service. +var errBoom = errors.New("boom") + +func runOperationsListCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewOperationsListCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func operation(id, status string) apptags.TagOperation { + return apptags.TagOperation{ + ID: id, + TagID: "a6217129-be72-4b02-a42c-9c431574e524", + TagName: "my-tag", + Type: "bulk_create", + Status: status, + TotalCount: 100, + ProcessedCount: 40, + SuccessfulCount: 38, + CreatedAt: time.Unix(0, 0).UTC(), + } +} + +// operationsNoCallService asserts the service is never reached, proving a bad +// input was rejected at the command boundary. +func operationsNoCallService(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Times(0) + return m +} + +func TestTagsOperationsListCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Return( + apptags.OperationsResult{ + Meta: okMeta(), + Operations: []apptags.TagOperation{operation("op-1", "running"), operation("op-2", "succeeded")}, + TotalSize: 2, + }, nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Operations (2)") + require.Contains(t, stdout, "op-1") + require.Contains(t, stdout, "bulk_create") + require.Contains(t, stdout, "40/100") + }, + }, + { + name: "short output reports the API total when truncated", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Return( + apptags.OperationsResult{ + Meta: okMeta(), + Operations: []apptags.TagOperation{operation("op-1", "running")}, + TotalSize: 9, + }, nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Operations (1 of 9)") + }, + }, + { + name: "empty result renders a friendly message", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Return( + apptags.OperationsResult{Meta: okMeta()}, nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "No operations found.") + }, + }, + { + name: "no tag argument leaves the tag unset for org-wide listing", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, params apptags.OperationsParams) (apptags.OperationsResult, cenclierrors.CencliError) { + require.False(t, params.TagID.IsPresent()) + return apptags.OperationsResult{Meta: okMeta()}, nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "tag argument and filters are threaded to the service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, params apptags.OperationsParams) (apptags.OperationsResult, cenclierrors.CencliError) { + require.True(t, params.TagID.IsPresent()) + require.Equal(t, "my-tag", params.TagID.MustGet().String()) + require.Equal(t, mo.Some("bulk_delete"), params.Type) + require.Equal(t, mo.Some("failed"), params.Status) + require.Equal(t, mo.Some("create_time_asc"), params.OrderBy) + require.Equal(t, mo.Some(uint64(25)), params.PageSize) + return apptags.OperationsResult{Meta: okMeta()}, nil + }) + return m + }, + args: []string{ + "my-tag", "--type", "bulk_delete", "--status", "failed", + "--order-by", "create_time_asc", "--page-size", "25", + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "blank filters are omitted rather than sent empty", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, params apptags.OperationsParams) (apptags.OperationsResult, cenclierrors.CencliError) { + require.False(t, params.Type.IsPresent()) + require.False(t, params.Status.IsPresent()) + return apptags.OperationsResult{Meta: okMeta()}, nil + }) + return m + }, + args: []string{"--type", " ", "--status", ""}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "json output renders the operations", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Return( + apptags.OperationsResult{ + Meta: okMeta(), + Operations: []apptags.TagOperation{operation("op-1", "succeeded")}, + TotalSize: 1, + }, nil) + return m + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"id": "op-1"`) + require.Contains(t, stdout, `"status": "succeeded"`) + // Absent optional fields must not surface as empty strings. + require.NotContains(t, stdout, `"error_message"`) + require.NotContains(t, stdout, `"query"`) + }, + }, + { + name: "partial error is printed after the data", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Return( + apptags.OperationsResult{ + Meta: okMeta(), + Operations: []apptags.TagOperation{operation("op-1", "succeeded")}, + TotalSize: 1, + PartialError: cenclierrors.ToPartialError(cenclierrors.NewCencliError(errBoom)), + }, nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "op-1") + require.Contains(t, stderr, "boom") + }, + }, + { + name: "empty tag argument is rejected before the service", + service: operationsNoCallService, + args: []string{" "}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag name or ID is required") + }, + }, + { + name: "too many arguments are rejected", + service: operationsNoCallService, + args: []string{"my-tag", "extra"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "max-pages of zero is rejected before the service", + service: operationsNoCallService, + args: []string{"--max-pages", "0"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "must be -1 or >= 1") + }, + }, + { + name: "page-size above the API maximum is rejected before the service", + service: operationsNoCallService, + args: []string{"--page-size", "1001"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "1000") + }, + }, + { + name: "service error is returned", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListOperations(gomock.Any(), gomock.Any()).Return( + apptags.OperationsResult{}, cenclierrors.NewCencliError(errBoom)) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runOperationsListCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/operationwait.go b/internal/command/tags/operationwait.go new file mode 100644 index 0000000..0283775 --- /dev/null +++ b/internal/command/tags/operationwait.go @@ -0,0 +1,160 @@ +package tags + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" +) + +// defaultWaitTimeout bounds --wait so a stalled job cannot hang a script +// indefinitely. The operation keeps running server-side either way. +const defaultWaitTimeout = 30 * time.Minute + +// parseWaitFlags reads the --wait/--timeout pair every command that can follow an +// operation shares. A zero timeout means "no limit", matching the global +// --timeout-http, and a negative one is rejected rather than expiring before the +// first poll. +func parseWaitFlags( + cmd *cobra.Command, + waitFlag flags.BoolFlag, + timeoutFlag flags.HumanDurationFlag, +) (bool, mo.Option[time.Duration], cenclierrors.CencliError) { + none := mo.None[time.Duration]() + + wait, err := waitFlag.Value() + if err != nil { + return false, none, err + } + + timeout, err := timeoutFlag.Value() + if err != nil { + return false, none, err + } + + // A timeout only means something while polling; silently ignoring it would + // make the flag look like it worked. + if !wait && cmd.Flags().Changed("timeout") { + return false, none, NewTimeoutWithoutWaitError() + } + + if timeout.IsPresent() { + switch d := timeout.MustGet(); { + case d < 0: + return false, none, NewInvalidWaitTimeoutError(d) + case d == 0: + timeout = none + } + } + + return wait, timeout, nil +} + +// waitForOperation polls an operation to completion behind a spinner. Shared by +// every command that can wait on a bulk job. +func waitForOperation( + ctx context.Context, + base *command.BaseCommand, + logger *slog.Logger, + svc tags.Service, + params tags.WaitParams, +) (tags.GetOperationResult, cenclierrors.CencliError) { + var result tags.GetOperationResult + err := base.WithProgress(ctx, logger, "Waiting for operation to finish...", + func(pctx context.Context) cenclierrors.CencliError { + var waitErr cenclierrors.CencliError + result, waitErr = svc.WaitForOperation(pctx, params) + return waitErr + }) + return result, err +} + +// followSubmittedOperation polls a job that was just submitted and returns the +// finished operation. A wait that ends early still points the user at the job, +// which keeps running server-side regardless. Shared by bulk assign and bulk +// unassign, which differ only in what they submitted. +func followSubmittedOperation( + ctx context.Context, + base *command.BaseCommand, + logger *slog.Logger, + svc tags.Service, + params tags.WaitParams, +) (tags.TagOperation, cenclierrors.CencliError) { + result, err := waitForOperation(ctx, base, logger, svc, params) + if err != nil { + quiet := base.Config().Quiet + if cenclierrors.IsInterrupted(err) { + printOperationStillRunningNote(quiet, params.TagID.String(), params.OperationID) + } else { + printOperationTrackHint(quiet, params.TagID.String(), params.OperationID) + } + return tags.TagOperation{}, err + } + return result.Operation, nil +} + +// reportOperationTerminalStatus maps a finished operation onto the exit code. A +// capped run still succeeded, so it warns rather than failing. Only a wait calls +// this: reading a failed operation is itself a successful read. +func reportOperationTerminalStatus(op tags.TagOperation) cenclierrors.CencliError { + switch op.Status { + case statusFailed: + return NewOperationFailedError(op) + case statusCancelled: + return NewOperationCancelledError(op) + case statusLimitReached: + msg := fmt.Sprintf( + "Warning: operation %s stopped at its asset limit after %d of %d asset(s).", + op.ID, op.SuccessfulCount, op.TotalCount) + if op.StatusMessage != nil && *op.StatusMessage != "" { + msg = fmt.Sprintf("%s %s", msg, *op.StatusMessage) + } + formatter.Println(formatter.Stderr, styles.GlobalStyles.Warning.Render(msg)) + return nil + default: + return nil + } +} + +// printOperationStillRunningNote reminds the user that interrupting the poll does +// not stop the job, and how to pick tracking back up. +func printOperationStillRunningNote(quiet bool, tagID, operationID string) { + if quiet { + return + } + formatter.Println(formatter.Stderr, styles.GlobalStyles.Warning.Render( + "Stopped waiting; the operation continues server-side.")) + printOperationTrackHint(quiet, tagID, operationID) +} + +// printOperationTrackHint tells the user how to follow an operation they did not +// wait on. It stays silent without an operation to name, so the hint is never a +// half-written command. +func printOperationTrackHint(quiet bool, tagID, operationID string) { + if quiet || operationID == "" { + return + } + formatter.Println(formatter.Stderr, fmt.Sprintf( + "Track with: censys tags operations get %s %s --wait", shellArg(tagID), operationID)) +} + +// shellArg quotes a value only when pasting it back into a shell would otherwise +// split or mangle it. Tag names allow spaces, which would silently turn the +// hinted command into a different one. +func shellArg(value string) string { + if strings.ContainsAny(value, " \t\n\"'\\$`*?&|;<>()[]{}#~!") { + return fmt.Sprintf("%q", value) + } + return value +} diff --git a/internal/command/tags/short.go b/internal/command/tags/short.go new file mode 100644 index 0000000..815e52b --- /dev/null +++ b/internal/command/tags/short.go @@ -0,0 +1,482 @@ +package tags + +import ( + "fmt" + "strconv" + "strings" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" + "github.com/censys/cencli/internal/pkg/ui/rawtable" +) + +// detailTimeLayout is the timestamp format the detail views and confirmation +// prompts share. It keeps the zone, so a value the user gave in local time is +// never shown back to them as another. +const detailTimeLayout = "2006-01-02 15:04:05 MST" + +// RenderShort renders the tag list as a styled table (TTY-aware). +func (c *ListCommand) RenderShort() cenclierrors.CencliError { + if len(c.result.Tags) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo tags found.\n") + return nil + } + + columns := []rawtable.Column[tags.Tag]{ + { + Title: "Name", + String: func(t tags.Tag) string { return t.Name }, + Style: func(s string, _ tags.Tag) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Privacy", + String: func(t tags.Tag) string { return t.Privacy }, + Style: func(s string, _ tags.Tag) string { + return styles.NewStyle(styles.ColorSage).Render(s) + }, + }, + { + Title: "Description", + String: func(t tags.Tag) string { + if t.Description != nil && *t.Description != "" { + return *t.Description + } + return "-" + }, + Style: func(s string, _ tags.Tag) string { + return styles.NewStyle(styles.ColorOffWhite).Render(s) + }, + }, + { + Title: "Created By", + String: func(t tags.Tag) string { return t.CreatedBy }, + Style: func(s string, _ tags.Tag) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Created At", + String: func(t tags.Tag) string { return t.CreatedAt.Format("2006-01-02 15:04") }, + Style: func(s string, _ tags.Tag) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[tags.Tag](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[tags.Tag](!formatter.StdoutIsTTY()), + ) + + // Surface the API's total when it exceeds what was fetched (e.g. paginated + // with --max-pages), so users know the listing is truncated. + countText := fmt.Sprintf("Tags (%d)", len(c.result.Tags)) + if c.result.TotalSize > int64(len(c.result.Tags)) { + countText = fmt.Sprintf("Tags (%d of %d)", len(c.result.Tags), c.result.TotalSize) + } + title := styles.GlobalStyles.Signature.Bold(true).Render(countText) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(c.result.Tags)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +// RenderShort renders a tag's assignments as a styled table (TTY-aware). +func (c *AssignmentsCommand) RenderShort() cenclierrors.CencliError { + if len(c.result.Assignments) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo assignments found.\n") + return nil + } + + columns := []rawtable.Column[tags.Assignment]{ + { + Title: "Asset", + String: func(a tags.Assignment) string { return a.AssetID }, + Style: func(s string, _ tags.Assignment) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Type", + String: func(a tags.Assignment) string { return a.AssetType }, + Style: func(s string, _ tags.Assignment) string { + return styles.NewStyle(styles.ColorSage).Render(s) + }, + }, + { + Title: "Created By", + String: func(a tags.Assignment) string { return a.CreatedBy }, + Style: func(s string, _ tags.Assignment) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Created At", + String: func(a tags.Assignment) string { return a.CreatedAt.Format("2006-01-02 15:04") }, + Style: func(s string, _ tags.Assignment) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[tags.Assignment](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[tags.Assignment](!formatter.StdoutIsTTY()), + ) + + // Show the API's total when it exceeds what was fetched, so a truncated + // listing says so. + countText := fmt.Sprintf("Assignments (%d)", len(c.result.Assignments)) + if c.result.TotalSize > int64(len(c.result.Assignments)) { + countText = fmt.Sprintf("Assignments (%d of %d)", len(c.result.Assignments), c.result.TotalSize) + } + title := styles.GlobalStyles.Signature.Bold(true).Render(countText) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(c.result.Assignments)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +// failureReason renders a per-asset error for a table cell, or "-" for a row +// that succeeded. The status is worth the characters: it distinguishes an asset +// that already carries the tag (409) from one the caller cannot touch (403). +func failureReason(detail string, status *int64) string { + if detail == "" { + return "-" + } + if status == nil { + return detail + } + return fmt.Sprintf("%s (%d)", detail, *status) +} + +// RenderShort renders the per-asset assignment outcomes as a styled table +// (TTY-aware), or the tracking operation when the assignment was a bulk job. +func (c *AssignCommand) RenderShort() cenclierrors.CencliError { + if c.bulk { + return renderOperationDetail(c.operation) + } + + views := c.assignmentViews() + if len(views) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo assets assigned.\n") + return nil + } + + columns := []rawtable.Column[assignedAsset]{ + { + Title: "Asset", + String: func(a assignedAsset) string { return a.Asset }, + Style: func(s string, _ assignedAsset) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Type", + String: func(a assignedAsset) string { + if a.AssetType == "" { + return "-" + } + return a.AssetType + }, + Style: func(s string, _ assignedAsset) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Status", + String: func(a assignedAsset) string { + if a.Assigned { + return "assigned" + } + return "failed" + }, + Style: func(s string, a assignedAsset) string { + if a.Assigned { + return styles.NewStyle(styles.ColorSage).Render(s) + } + return styles.NewStyle(styles.ColorRed).Render(s) + }, + }, + { + // Its own column so a long message cannot stretch Status. + Title: "Error", + String: func(a assignedAsset) string { return failureReason(a.Error, a.ErrorStatus) }, + Style: func(s string, _ assignedAsset) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[assignedAsset](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[assignedAsset](!formatter.StdoutIsTTY()), + ) + + header := fmt.Sprintf("Assigned tag %q to %d of %d asset(s)", + c.result.TagID, len(c.result.Assignments), len(views)) + title := styles.GlobalStyles.Signature.Bold(true).Render(header) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(views)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +// RenderShort renders the per-asset unassignment outcomes as a styled table +// (TTY-aware), or the tracking operation when the removal was a bulk job. +func (c *UnassignCommand) RenderShort() cenclierrors.CencliError { + if c.bulk { + return renderOperationDetail(c.operation) + } + + views := c.unassignmentViews() + if len(views) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo assets unassigned.\n") + return nil + } + + columns := []rawtable.Column[unassignedAsset]{ + { + Title: "Asset", + String: func(a unassignedAsset) string { return a.Asset }, + Style: func(s string, _ unassignedAsset) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Type", + String: func(a unassignedAsset) string { + if a.AssetType == "" { + return "-" + } + return a.AssetType + }, + Style: func(s string, _ unassignedAsset) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Status", + String: func(a unassignedAsset) string { + if a.Unassigned { + return "unassigned" + } + return "failed" + }, + Style: func(s string, a unassignedAsset) string { + if a.Unassigned { + return styles.NewStyle(styles.ColorSage).Render(s) + } + return styles.NewStyle(styles.ColorRed).Render(s) + }, + }, + { + // Its own column so a long message cannot stretch Status. + Title: "Error", + String: func(a unassignedAsset) string { return failureReason(a.Error, a.ErrorStatus) }, + Style: func(s string, _ unassignedAsset) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[unassignedAsset](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[unassignedAsset](!formatter.StdoutIsTTY()), + ) + + header := fmt.Sprintf("Unassigned tag %q from %d of %d asset(s)", + c.result.TagID, len(c.result.Unassigned), len(views)) + title := styles.GlobalStyles.Signature.Bold(true).Render(header) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(views)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +// RenderShort renders the bulk tag operations as a styled table (TTY-aware). +func (c *OperationsListCommand) RenderShort() cenclierrors.CencliError { + if len(c.result.Operations) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo operations found.\n") + return nil + } + + columns := []rawtable.Column[tags.TagOperation]{ + { + Title: "ID", + String: func(o tags.TagOperation) string { return o.ID }, + Style: func(s string, _ tags.TagOperation) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Tag", + String: func(o tags.TagOperation) string { return o.TagName }, + Style: func(s string, _ tags.TagOperation) string { + return styles.NewStyle(styles.ColorOffWhite).Render(s) + }, + }, + { + Title: "Type", + String: func(o tags.TagOperation) string { return o.Type }, + Style: func(s string, _ tags.TagOperation) string { + return styles.NewStyle(styles.ColorSage).Render(s) + }, + }, + { + Title: "Status", + String: func(o tags.TagOperation) string { return o.Status }, + Style: func(s string, o tags.TagOperation) string { return styleOperationStatus(s, o.Status) }, + }, + { + Title: "Progress", + String: operationProgress, + Style: func(s string, _ tags.TagOperation) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Created At", + String: func(o tags.TagOperation) string { return o.CreatedAt.Format("2006-01-02 15:04") }, + Style: func(s string, _ tags.TagOperation) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[tags.TagOperation](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[tags.TagOperation](!formatter.StdoutIsTTY()), + ) + + // Show the API's total when it exceeds what was fetched, so a truncated + // listing says so. + countText := fmt.Sprintf("Operations (%d)", len(c.result.Operations)) + if c.result.TotalSize > int64(len(c.result.Operations)) { + countText = fmt.Sprintf("Operations (%d of %d)", len(c.result.Operations), c.result.TotalSize) + } + title := styles.GlobalStyles.Signature.Bold(true).Render(countText) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(c.result.Operations)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +// RenderShort renders a single operation as a labeled detail view (TTY-aware). +func (c *OperationsGetCommand) RenderShort() cenclierrors.CencliError { + return renderOperationDetail(c.result.Operation) +} + +// renderOperationDetail renders one bulk operation as a labeled detail view +// (TTY-aware). Shared by `operations get` and the bulk assign submit, so a job +// reads the same however you arrived at it. +func renderOperationDetail(op tags.TagOperation) cenclierrors.CencliError { + var out strings.Builder + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Tag Operation ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + writeField(&out, "ID", op.ID) + writeField(&out, "Tag", op.TagName) + writeField(&out, "Tag ID", op.TagID) + writeField(&out, "Type", op.Type) + writeField(&out, "Status", styleOperationStatus(op.Status, op.Status)) + writeField(&out, "Progress", operationProgress(op)) + writeField(&out, "Succeeded", strconv.FormatInt(op.SuccessfulCount, 10)) + + // Only bulk_create operations carry the query that produced them. + if op.Query != nil && *op.Query != "" { + writeField(&out, "Query", *op.Query) + } + + writeField(&out, "Created At", op.CreatedAt.Format(detailTimeLayout)) + if op.EndedAt != nil { + writeField(&out, "Ended At", op.EndedAt.Format(detailTimeLayout)) + } + if op.StatusMessage != nil && *op.StatusMessage != "" { + writeField(&out, "Message", *op.StatusMessage) + } + if op.ErrorMessage != nil && *op.ErrorMessage != "" { + writeField(&out, "Error", *op.ErrorMessage) + } + + formatter.Println(formatter.Stdout, out.String()) + return nil +} + +// operationProgress renders how far an operation got. The total is unknown until +// completion for bulk_delete, so it is only shown once the API reports one. +func operationProgress(o tags.TagOperation) string { + if o.TotalCount > 0 { + return fmt.Sprintf("%d/%d", o.ProcessedCount, o.TotalCount) + } + return strconv.FormatInt(o.ProcessedCount, 10) +} + +// styleOperationStatus colors a status by outcome: done, capped, or broken. +func styleOperationStatus(s, status string) string { + switch status { + case statusSucceeded: + return styles.NewStyle(styles.ColorSage).Render(s) + case statusFailed, statusCancelled: + return styles.NewStyle(styles.ColorRed).Render(s) + case statusLimitReached: + return styles.GlobalStyles.Warning.Render(s) + default: + return styles.NewStyle(styles.ColorTeal).Render(s) + } +} + +// renderTagDetail renders a single tag as a labeled detail view (TTY-aware), +// under the given section header. Shared by the get and create commands. +func renderTagDetail(header string, t tags.Tag) cenclierrors.CencliError { + var out strings.Builder + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render(header)) + out.WriteRune('\n') + out.WriteRune('\n') + + writeField(&out, "Name", t.Name) + writeField(&out, "ID", t.ID) + writeField(&out, "Privacy", t.Privacy) + + description := "-" + if t.Description != nil && *t.Description != "" { + description = *t.Description + } + writeField(&out, "Description", description) + + writeField(&out, "Created By", t.CreatedBy) + writeField(&out, "Created At", t.CreatedAt.Format(detailTimeLayout)) + writeField(&out, "Updated At", t.UpdatedAt.Format(detailTimeLayout)) + + // Only `get --asset-count` populates the count. + if t.AssetCount != nil { + writeField(&out, "Assets", strconv.FormatInt(*t.AssetCount, 10)) + } + + formatter.Println(formatter.Stdout, out.String()) + return nil +} + +// writeField appends a padded label / value line to a detail view. +func writeField(out *strings.Builder, label, value string) { + labelStyled := styles.GlobalStyles.Primary.Render(fmt.Sprintf("%-13s", label+":")) + valueStyled := styles.GlobalStyles.Comment.Render(value) + fmt.Fprintf(out, " %s %s\n", labelStyled, valueStyled) +} diff --git a/internal/command/tags/tags.go b/internal/command/tags/tags.go new file mode 100644 index 0000000..72d371e --- /dev/null +++ b/internal/command/tags/tags.go @@ -0,0 +1,74 @@ +package tags + +import ( + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +const cmdName = "tags" + +// Command is the parent tags command that groups tag-management subcommands. +type Command struct { + *command.BaseCommand +} + +var _ command.Command = (*Command)(nil) + +// NewTagsCommand creates a new tags command with all subcommands. +func NewTagsCommand(cmdContext *command.Context) *Command { + return &Command{BaseCommand: command.NewBaseCommand(cmdContext)} +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Manage tags and tag assignments for your organization" +} + +func (c *Command) Long() string { + return `Manage tags and tag assignments for your organization. + +Use --org-id on any subcommand to target a different organization; otherwise your stored organization ID is used.` +} + +func (c *Command) Args() command.PositionalArgs { + return command.ExactArgs(0) +} + +func (c *Command) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *Command) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort} +} + +func (c *Command) Init() error { + return c.AddSubCommands( + NewListCommand(c.Context), + NewGetCommand(c.Context), + NewCreateCommand(c.Context), + NewUpdateCommand(c.Context), + NewDeleteCommand(c.Context), + NewAssignCommand(c.Context), + NewUnassignCommand(c.Context), + NewAssignmentsCommand(c.Context), + NewOperationsCommand(c.Context), + ) +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + // Parent command shows help when run without subcommands. + if err := cmd.Help(); err != nil { + return cenclierrors.NewCencliError(err) + } + return nil +} diff --git a/internal/command/tags/tags_test.go b/internal/command/tags/tags_test.go new file mode 100644 index 0000000..42be477 --- /dev/null +++ b/internal/command/tags/tags_test.go @@ -0,0 +1,228 @@ +package tags + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" +) + +func okMeta() *responsemeta.ResponseMeta { + return &responsemeta.ResponseMeta{ + Method: "GET", + URL: "https://api.censys.io/v3/tags", + Status: 200, + Latency: 100 * time.Millisecond, + } +} + +func tag(name string) apptags.Tag { + return apptags.Tag{ID: name + "-id", Name: name, Privacy: "shared", CreatedBy: "creator", CreatedAt: time.Unix(0, 0).UTC()} +} + +func runListCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewListCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func TestTagsListCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return( + apptags.ListResult{Meta: okMeta(), Tags: []apptags.Tag{tag("alpha"), tag("beta")}, TotalSize: 2}, + nil, + ) + return m + }, + args: nil, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "alpha") + require.Contains(t, stdout, "beta") + require.Contains(t, stdout, "Tags (2)") + }, + }, + { + name: "success - json output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return( + apptags.ListResult{Meta: okMeta(), Tags: []apptags.Tag{tag("alpha")}, TotalSize: 1}, + nil, + ) + return m + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"name": "alpha"`) + }, + }, + { + name: "short output shows total when truncated by pagination", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return( + apptags.ListResult{Meta: okMeta(), Tags: []apptags.Tag{tag("alpha"), tag("beta")}, TotalSize: 42}, + nil, + ) + return m + }, + args: nil, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Tags (2 of 42)") + }, + }, + { + name: "success - empty result", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return(apptags.ListResult{Meta: okMeta()}, nil) + return m + }, + args: nil, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "No tags found") + }, + }, + { + name: "filters and pagination threaded to service", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.ListParams) (apptags.ListResult, cenclierrors.CencliError) { + require.Equal(t, "shared", params.Privacy.MustGet()) + require.Equal(t, "my-tag", params.Name.MustGet()) + require.Equal(t, "f47ac10b-58cc-4372-a567-0e02b2c3d479", params.CreatedBy.MustGet()) + require.Equal(t, "name_desc", params.OrderBy.MustGet()) + require.Equal(t, uint64(50), params.PageSize.MustGet()) + require.Equal(t, uint64(3), params.MaxPages.MustGet()) + return apptags.ListResult{Meta: okMeta(), Tags: []apptags.Tag{tag("my-tag")}, TotalSize: 1}, nil + }, + ) + return m + }, + args: []string{ + "--privacy", "shared", + "--name", "my-tag", + "--created-by", "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "--order-by", "name_desc", + "--page-size", "50", + "--max-pages", "3", + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "my-tag") + }, + }, + { + name: "error - unexpected positional arg", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"unexpected"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "error - invalid max-pages", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"--max-pages", "0"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "max-pages") + }, + }, + { + // The API declares created_by as a UUID and 422s on anything else, so + // it is rejected here instead of costing a round trip. + name: "error - non-UUID created-by", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"--created-by", "not-a-uuid"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stdout, stderr, err := runListCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} + +func TestTagsListCommand_PartialError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := tagsmocks.NewMockTagsService(ctrl) + baseErr := cenclierrors.NewCencliError(errors.New("network error on page 2")) + m.EXPECT().ListTags(gomock.Any(), gomock.Any()).Return( + apptags.ListResult{ + Meta: okMeta(), + Tags: []apptags.Tag{tag("alpha")}, + TotalSize: 5, + PartialError: cenclierrors.ToPartialError(baseErr), + }, + nil, + ) + + stdout, stderr, err := runListCommand(t, m, nil) + require.NoError(t, err) + require.Contains(t, stdout, "alpha", "should render partial data to stdout") + require.Contains(t, stderr, "network error on page 2", "should print the partial error to stderr") +} diff --git a/internal/command/tags/unassign.go b/internal/command/tags/unassign.go new file mode 100644 index 0000000..e4e8648 --- /dev/null +++ b/internal/command/tags/unassign.go @@ -0,0 +1,434 @@ +package tags + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/term" + "github.com/censys/cencli/internal/pkg/ui/form" +) + +const ( + unassignCmdName = "unassign" + + // unassignIndexLagNote warns that a removed assignment is not immediately + // gone from search. Printed by both input modes, since both mutate assignments. + unassignIndexLagNote = "Note: unassigned tags may take a few minutes to disappear from `tags:` search results." +) + +// UnassignCommand implements `tags unassign [asset...]`, removing a tag +// either from explicit assets (given positionally or via --input-file) or, with +// --all or a time filter, from the assignments matching that filter. The filtered +// form submits an asynchronous bulk job and reports the operation tracking it. +type UnassignCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags unassignCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + assetIDs []string + yes bool + // bulk state - only meaningful when bulk is true + bulk bool + all bool + createdBefore mo.Option[time.Time] + createdAfter mo.Option[time.Time] + wait bool + timeout mo.Option[time.Duration] + // result stores the explicit-mode unassignment outcome for rendering + result tags.UnassignResult + // operation stores the bulk job for rendering: the submitted operation, then + // the finished one once --wait has polled it + operation tags.TagOperation + // seams - overridable in tests; defaulted in NewUnassignCommand + confirm func(ctx context.Context, message string) (bool, error) + stdinIsTTY func() bool +} + +type unassignCommandFlags struct { + orgID flags.OrgIDFlag + inputFile flags.FileFlag + all flags.BoolFlag + createdBefore flags.TimestampFlag + createdAfter flags.TimestampFlag + wait flags.BoolFlag + timeout flags.HumanDurationFlag + yes flags.BoolFlag +} + +// unassignedAsset is the data-mode payload for a single unassignment outcome. +// Error is the one-line reason, and ErrorStatus the HTTP status behind it, so a +// script can branch on the code without parsing a message. +type unassignedAsset struct { + Asset string `json:"asset" yaml:"asset"` + AssetType string `json:"asset_type,omitempty" yaml:"asset_type,omitempty"` + PlatformRef string `json:"platform_ref,omitempty" yaml:"platform_ref,omitempty"` + Unassigned bool `json:"unassigned" yaml:"unassigned"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` + ErrorStatus *int64 `json:"error_status,omitempty" yaml:"error_status,omitempty"` +} + +var _ command.Command = (*UnassignCommand)(nil) + +func NewUnassignCommand(cmdContext *command.Context) *UnassignCommand { + return &UnassignCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + confirm: form.Confirm, + stdinIsTTY: func() bool { return term.IsTTY(os.Stdin) }, + } +} + +func (c *UnassignCommand) Use() string { + return fmt.Sprintf("%s [asset...]", unassignCmdName) +} + +func (c *UnassignCommand) Short() string { + return "Unassign a tag from one or more assets" +} + +func (c *UnassignCommand) Long() string { + return `Unassign a tag, by its name or UUID, from one or more assets (host IPs, certificate SHA-256 fingerprints, or web property hostname:port). + +Assets can be passed as positional arguments or read from a file (or STDIN) with --input-file. Assets of different types can be mixed in a single call. Each asset is unassigned independently: if one fails the rest still proceed, and the per-asset outcomes are reported. + +Use --all instead to remove every one of the tag's assignments, or --created-before/--created-after to remove only those created in a time window. Either form starts an asynchronous bulk job and reports the operation tracking it; it cannot be combined with explicit assets, and --all cannot be narrowed by a time filter. Bulk unassignment always asks for confirmation unless --yes is set.` +} + +func (c *UnassignCommand) Examples() []string { + return []string{ + " ", + " ", + " ", + " ", + " # asset types can be mixed", + " --input-file ", + " --input-file - # read assets from STDIN", + " --all # remove every one of the tag's assignments", + " --created-before 2026-01-01T00:00:00Z # only assignments made before then", + " --all --wait # poll until the job finishes", + } +} + +func (c *UnassignCommand) Args() command.PositionalArgs { + // At least the tag; assets may instead come from --input-file, or the bulk + // filters may select them. + return command.MinimumNArgs(1) +} + +func (c *UnassignCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *UnassignCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *UnassignCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.inputFile = flags.NewFileFlag(c.Flags(), false, "input-file", "i", "file to read the assets from (or - for STDIN). Overrides positional asset arguments.") + c.flags.all = flags.NewBoolFlag(c.Flags(), "all", "", false, + "remove every one of the tag's assignments. Starts a bulk job instead of unassigning explicit assets.") + c.flags.createdBefore = flags.NewTimestampFlag(c.Flags(), false, "created-before", "", mo.None[time.Time](), + "only unassign assignments created before this time. Starts a bulk job.") + c.flags.createdAfter = flags.NewTimestampFlag(c.Flags(), false, "created-after", "", mo.None[time.Time](), + "only unassign assignments created after this time. Starts a bulk job.") + c.flags.wait = flags.NewBoolFlag(c.Flags(), "wait", "w", false, + "poll the bulk job until it reaches a final status (requires --all or a time filter)") + c.flags.timeout = flags.NewHumanDurationFlag(c.Flags(), false, "timeout", "", + mo.Some(defaultWaitTimeout), "how long to wait before giving up (requires --wait) - use 0 for no limit") + c.flags.yes = flags.NewBoolFlag(c.Flags(), "yes", "y", false, + "skip the confirmation prompt (requires --all or a time filter)") + return nil +} + +func (c *UnassignCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + yes, err := c.flags.yes.Value() + if err != nil { + return err + } + c.yes = yes + + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + + if err := c.parseModeFlags(cmd, args); err != nil { + return err + } + + if !c.bulk { + c.assetIDs, err = gatherAssetIDs(cmd, c.flags.inputFile, args) + if err != nil { + return err + } + } else if !c.yes && !c.stdinIsTTY() { + // Only a bulk removal confirms. Gated before the service resolves, so a + // non-interactive run without --yes fails before auth or submission. + return NewConfirmationRequiredError() + } + + return c.resolveTagsService() +} + +// parseModeFlags decides between explicit and bulk unassignment and rejects the +// combinations that cannot mean anything. Bulk is only ever chosen by --all or a +// time filter; it is never inferred from missing asset arguments. +func (c *UnassignCommand) parseModeFlags(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.all, err = c.flags.all.Value() + if err != nil { + return err + } + c.createdBefore, err = c.flags.createdBefore.Value(c.Config().DefaultTZ) + if err != nil { + return err + } + c.createdAfter, err = c.flags.createdAfter.Value(c.Config().DefaultTZ) + if err != nil { + return err + } + + timeFiltered := c.createdBefore.IsPresent() || c.createdAfter.IsPresent() + c.bulk = c.all || timeFiltered + + if c.bulk { + // --all already means every assignment, so narrowing it is contradictory. + if c.all && timeFiltered { + return NewAllWithTimeFilterError() + } + if len(args) > 1 || c.flags.inputFile.IsSet() { + return NewUnassignModeConflictError() + } + // Reject an inverted window here as well as in the service, so it fails + // before credentials are needed - the same reason a blank --query is + // caught in assign's PreRun. + if err := tags.ValidateTimeWindow(c.createdBefore, c.createdAfter); err != nil { + return err + } + } + + // Flags that only steer a bulk job would silently do nothing in explicit mode. + // --yes is one of them: explicit unassignment never prompts. + for _, name := range []string{"wait", "timeout", "yes"} { + if !c.bulk && cmd.Flags().Changed(name) { + return NewFlagRequiresAllError(name) + } + } + + c.wait, c.timeout, err = parseWaitFlags(cmd, c.flags.wait, c.flags.timeout) + return err +} + +func (c *UnassignCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + "yes", c.yes, + "bulk", c.bulk, + ) + + if c.bulk { + return c.runBulk(cmd, logger.With("wait", c.wait, "all", c.all)) + } + return c.runExplicit(cmd, logger.With("count", len(c.assetIDs))) +} + +// runExplicit removes the tag from each given asset, one lookup+delete per +// asset. It does not confirm: the assets were named on the command line. +func (c *UnassignCommand) runExplicit(cmd *cobra.Command, logger *slog.Logger) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + logger, + "Unassigning tag...", + func(pctx context.Context) cenclierrors.CencliError { + var unassignErr cenclierrors.CencliError + c.result, unassignErr = c.tagsSvc.Unassign(pctx, tags.UnassignParams{ + OrgID: c.orgID, + TagID: c.tagID, + AssetIDs: c.assetIDs, + }) + return unassignErr + }, + ) + if err != nil { + logger.Debug("unassign tag failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + if renderErr := c.PrintData(c, c.unassignmentViews()); renderErr != nil { + return renderErr + } + + if len(c.result.Unassigned) > 0 { + printNote(c.Config().Quiet, unassignIndexLagNote) + } + + if c.result.PartialError != nil { + formatter.PrintError(c.result.PartialError, cmd) + } + + // The results are already rendered; this only drives the exit code. + if len(c.result.Unassigned) == 0 && len(c.result.Failures) > 0 { + return NewAllAssetsFailedError(len(c.result.Failures), len(c.assetIDs), "unassigned") + } + + return nil +} + +// runBulk submits a filter-driven bulk removal and reports the operation tracking +// it, optionally polling that operation until it finishes. +func (c *UnassignCommand) runBulk(cmd *cobra.Command, logger *slog.Logger) cenclierrors.CencliError { + if !c.yes { + confirmed, err := confirmAction(cmd.Context(), c.confirm, c.confirmMessage()) + if err != nil { + return err + } + if !confirmed { + formatter.Println(formatter.Stderr, "Unassign aborted.") + return nil + } + } + + var submitted tags.BulkUnassignResult + err := c.WithProgress( + cmd.Context(), + logger, + "Submitting bulk unassignment...", + func(pctx context.Context) cenclierrors.CencliError { + var submitErr cenclierrors.CencliError + submitted, submitErr = c.tagsSvc.BulkUnassign(pctx, tags.BulkUnassignParams{ + OrgID: c.orgID, + TagID: c.tagID, + CreatedBefore: c.createdBefore, + CreatedAfter: c.createdAfter, + }) + return submitErr + }, + ) + if err != nil { + logger.Debug("submit bulk unassignment failed", "error", err) + return err + } + + c.operation = submitted.Operation + c.PrintAppResponseMeta(submitted.Meta) + + // The job now exists server-side whatever happens next, so any exit that + // leaves it unfinished says how to pick it back up. + if c.wait { + if waitErr := c.waitForSubmitted(cmd.Context(), logger); waitErr != nil { + return waitErr + } + } + + if renderErr := c.PrintData(c, c.operation); renderErr != nil { + return renderErr + } + + quiet := c.Config().Quiet + if !c.wait { + printOperationTrackHint(quiet, c.tagID.String(), c.operation.ID) + printNote(quiet, unassignIndexLagNote) + return nil + } + + if statusErr := reportOperationTerminalStatus(c.operation); statusErr != nil { + return statusErr + } + printNote(quiet, unassignIndexLagNote) + return nil +} + +// waitForSubmitted polls the job just submitted, replacing the operation being +// rendered with the finished one. +func (c *UnassignCommand) waitForSubmitted(ctx context.Context, logger *slog.Logger) cenclierrors.CencliError { + operation, err := followSubmittedOperation(ctx, c.BaseCommand, logger, c.tagsSvc, tags.WaitParams{ + OrgID: c.orgID, + TagID: c.tagID, + OperationID: c.operation.ID, + Timeout: c.timeout, + }) + if err != nil { + logger.Debug("wait for bulk unassignment failed", "error", err) + return err + } + + c.operation = operation + return nil +} + +// confirmMessage spells out the scope of a bulk removal, since the difference +// between wiping a tag and trimming a time window is not otherwise visible. +func (c *UnassignCommand) confirmMessage() string { + scope := "ALL assigned assets" + switch { + case c.createdBefore.IsPresent() && c.createdAfter.IsPresent(): + scope = fmt.Sprintf("assignments created between %s and %s", + c.createdAfter.MustGet().Format(detailTimeLayout), + c.createdBefore.MustGet().Format(detailTimeLayout)) + case c.createdBefore.IsPresent(): + scope = fmt.Sprintf("assignments created before %s", + c.createdBefore.MustGet().Format(detailTimeLayout)) + case c.createdAfter.IsPresent(): + scope = fmt.Sprintf("assignments created after %s", + c.createdAfter.MustGet().Format(detailTimeLayout)) + } + return fmt.Sprintf("Unassign tag %q from %s?", c.tagID.String(), scope) +} + +func (c *UnassignCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// unassignmentViews builds the render payload: successes first, then failures. +func (c *UnassignCommand) unassignmentViews() []unassignedAsset { + views := make([]unassignedAsset, 0, len(c.result.Unassigned)+len(c.result.Failures)) + for _, a := range c.result.Unassigned { + views = append(views, unassignedAsset{ + Asset: a.AssetID, + AssetType: a.AssetType, + PlatformRef: a.PlatformRef, + Unassigned: true, + }) + } + types := assetTypesByID(c.assetIDs) + for _, f := range c.result.Failures { + views = append(views, unassignedAsset{ + Asset: f.AssetID, + AssetType: types[f.AssetID], + Unassigned: false, + Error: f.Detail, + ErrorStatus: f.Status.ToPointer(), + }) + } + return views +} diff --git a/internal/command/tags/unassign_test.go b/internal/command/tags/unassign_test.go new file mode 100644 index 0000000..76a48d8 --- /dev/null +++ b/internal/command/tags/unassign_test.go @@ -0,0 +1,908 @@ +package tags + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +type unassignSeams struct { + confirm func(ctx context.Context, message string) (bool, error) + stdinIsTTY func() bool + // quiet stands in for the global --quiet flag, which lives on the real root + // command and so is not registered when a subcommand is mounted alone. + quiet bool +} + +func runUnassignCommand(t *testing.T, svc apptags.Service, seams unassignSeams, args []string, stdin io.Reader) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + if seams.quiet { + // PreRun re-reads the config from viper, so setting the struct field would + // be overwritten; viper is also where the real --quiet flag lands. + viper.Set("quiet", true) + } + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + cmd := NewUnassignCommand(cmdContext) + if seams.confirm != nil { + cmd.confirm = seams.confirm + } + if seams.stdinIsTTY != nil { + cmd.stdinIsTTY = seams.stdinIsTTY + } + rootCmd, buildErr := command.RootCommandToCobra(cmd) + require.NoError(t, buildErr) + + if stdin != nil { + rootCmd.SetIn(stdin) + } + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +// unassignResult builds a service result with the given removed assets and +// optional failures, echoing the tag identifier. +func unassignResult(tagID string, unassigned []string, failures map[string]string) apptags.UnassignResult { + res := apptags.UnassignResult{Meta: okMeta(), TagID: tagID} + for _, a := range unassigned { + res.Unassigned = append(res.Unassigned, apptags.Assignment{ + ID: a + "-id", AssetID: a, AssetType: "host", TagID: tagID, + PlatformRef: "https://platform.censys.io/hosts/" + a, + }) + } + for asset, msg := range failures { + res.Failures = append(res.Failures, apptags.AssignmentFailure{ + AssetID: asset, + Err: cenclierrors.NewCencliError(errors.New(msg)), + // The service reduces every failure to a one-line Detail; the views + // read that, not Err, so the fixture has to carry it too. + Detail: msg, + }) + } + // Mirrors the service: a partial error only when something also succeeded. + // A run where every asset failed is not partial, and the command turns it + // into a non-zero exit itself. + if len(res.Failures) > 0 && len(res.Unassigned) > 0 { + res.PartialError = cenclierrors.ToPartialError( + cenclierrors.NewCencliError(errors.New("some assets failed to unassign"))) + } + return res +} + +// unassignNoCallService returns a service that must not be called (validation or +// an aborted confirmation is expected before the service is reached). +func unassignNoCallService(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) +} + +// noPromptSeams fails the test if the confirm prompt is ever shown. +func noPromptSeams(t *testing.T) unassignSeams { + t.Helper() + return unassignSeams{ + confirm: func(_ context.Context, _ string) (bool, error) { + t.Fatal("confirm prompt should not be shown") + return false, nil + }, + stdinIsTTY: func() bool { return true }, + } +} + +func TestTagsUnassignCommand(t *testing.T) { + testCases := []struct { + name string + args []string + stdin io.Reader + seams func(t *testing.T) unassignSeams + service func(t *testing.T, ctrl *gomock.Controller) apptags.Service + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "single positional asset does not prompt", + args: []string{"alpha", "8.8.8.8"}, + seams: noPromptSeams, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.UnassignParams) (apptags.UnassignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8"}, p.AssetIDs) + return unassignResult("alpha", []string{"8.8.8.8"}, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stderr, "few minutes") + }, + }, + { + // Explicit unassignment does not prompt, however many assets are named: + // they were all typed by the caller. Only a bulk removal confirms. + name: "multiple assets do not prompt and are threaded in order", + args: []string{"alpha", "8.8.8.8", "1.1.1.1"}, + seams: noPromptSeams, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.UnassignParams) (apptags.UnassignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return unassignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "1.1.1.1") + }, + }, + { + // Nothing on stdin used to be fatal here; with no prompt to answer it + // is now just a normal run. + name: "multiple assets run without a terminal", + args: []string{"alpha", "8.8.8.8", "1.1.1.1"}, + seams: func(_ *testing.T) unassignSeams { return unassignSeams{stdinIsTTY: func() bool { return false }} }, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).Return( + unassignResult("alpha", []string{"8.8.8.8", "1.1.1.1"}, nil), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + // --yes has nothing to skip outside bulk mode, so it is rejected + // rather than ignored, like --wait and --timeout. + name: "--yes is rejected in explicit mode", + args: []string{"alpha", "8.8.8.8", "1.1.1.1", "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { return unassignNoCallService(ctrl) }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--yes only applies to a bulk unassignment") + }, + }, + { + name: "comma-separated positional assets are split", + args: []string{"alpha", "8.8.8.8,1.1.1.1"}, + seams: noPromptSeams, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.UnassignParams) (apptags.UnassignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return unassignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "stdin input via --input-file -", + args: []string{"alpha", "--input-file", "-"}, + stdin: bytes.NewBufferString("8.8.8.8\n1.1.1.1\n"), + seams: noPromptSeams, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.UnassignParams) (apptags.UnassignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return unassignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "unknown asset is rejected before the service is called", + args: []string{"alpha", "8.8.8.8", "not-an-asset"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { return unassignNoCallService(ctrl) }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "not-an-asset") + }, + }, + { + name: "no assets is an error", + args: []string{"alpha"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { return unassignNoCallService(ctrl) }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "empty tag id is rejected", + args: []string{" ", "8.8.8.8"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { return unassignNoCallService(ctrl) }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + }, + }, + { + name: "partial failure is surfaced to stderr but data still renders", + args: []string{"alpha", "8.8.8.8", "1.1.1.1"}, + seams: noPromptSeams, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).Return( + unassignResult("alpha", []string{"8.8.8.8"}, map[string]string{"1.1.1.1": "not assigned"}), + nil, + ) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stdout, "1.1.1.1") + require.Contains(t, stderr, "some assets failed to unassign") + }, + }, + { + name: "json output renders per-asset payload", + args: []string{"alpha", "8.8.8.8", "--output-format", "json"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).Return( + unassignResult("alpha", []string{"8.8.8.8"}, nil), nil) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"asset": "8.8.8.8"`) + require.Contains(t, stdout, `"unassigned": true`) + }, + }, + { + name: "org id flag is threaded to the service", + args: []string{"alpha", "8.8.8.8", "--org-id", "11111111-1111-1111-1111-111111111111"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.UnassignParams) (apptags.UnassignResult, cenclierrors.CencliError) { + require.True(t, p.OrgID.IsPresent()) + return unassignResult("alpha", p.AssetIDs, nil), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var seams unassignSeams + if tc.seams != nil { + seams = tc.seams(t) + } + stdout, stderr, err := runUnassignCommand(t, tc.service(t, ctrl), seams, tc.args, tc.stdin) + tc.assert(t, stdout, stderr, err) + }) + } +} + +// deleteOperation is operation()'s bulk_delete twin. TotalCount stays zero +// because the API only sets it once a bulk delete completes. +func deleteOperation(status string) apptags.TagOperation { + op := operation(testOperationID, status) + op.Type = "bulk_delete" + op.TotalCount = 0 + op.Query = nil + return op +} + +// finishedDeleteOperation is a bulk_delete job that has reached a terminal status. +func finishedDeleteOperation(status string) apptags.TagOperation { + ended := time.Unix(60, 0).UTC() + op := deleteOperation(status) + op.EndedAt = &ended + op.TotalCount = 100 + op.ProcessedCount = 100 + op.SuccessfulCount = 90 + return op +} + +// bulkUnassignSubmitted is what the service returns for an accepted bulk removal. +func bulkUnassignSubmitted(status string) apptags.BulkUnassignResult { + return apptags.BulkUnassignResult{Meta: okMeta(), Operation: deleteOperation(status)} +} + +// bulkUnassignNoCallService asserts neither removal path runs, proving the input +// was rejected at the command boundary. +func bulkUnassignNoCallService(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).Times(0) + return m +} + +// bulkUnassignSubmitOnly expects a submit and no polling. +func bulkUnassignSubmitOnly(ctrl *gomock.Controller, status string) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return(bulkUnassignSubmitted(status), nil) + return m +} + +// bulkUnassignSubmitAndWait expects a submit followed by polling that ends on the +// given status. +func bulkUnassignSubmitAndWait(ctrl *gomock.Controller, finalStatus string) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return(bulkUnassignSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{Meta: okMeta(), Operation: finishedDeleteOperation(finalStatus)}, nil) + return m +} + +// TestTagsUnassignCommand_AllAssetsFail mirrors the assign contract: when no +// asset succeeded the per-asset results still render and the exit is non-zero. +// Unassign reaches this easily, since an asset the tag was never on is a +// failure by design rather than a silent no-op. +func TestTagsUnassignCommand_AllAssetsFail(t *testing.T) { + allFailed := func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).Return( + unassignResult("alpha", nil, map[string]string{ + "8.8.8.8": "asset is not assigned to this tag", + "1.1.1.1": "asset is not assigned to this tag", + }), nil) + return m + } + + t.Run("short output lists every failed asset and exits non-zero", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, stderr, err := runUnassignCommand(t, allFailed(ctrl), noPromptSeams(t), + []string{"alpha", "8.8.8.8", "1.1.1.1"}, nil) + + require.Error(t, err) + require.Equal(t, 1, formatter.ExitCode(err)) + require.Contains(t, stdout, "8.8.8.8") + require.Contains(t, stdout, "1.1.1.1") + require.Contains(t, stdout, "not assigned") + require.Contains(t, err.Error(), "2 of 2 failed") + require.NotContains(t, stderr, "few minutes") + }) + + t.Run("json output still emits the full array", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + stdout, _, err := runUnassignCommand(t, allFailed(ctrl), noPromptSeams(t), + []string{"alpha", "8.8.8.8", "1.1.1.1", "--output-format", "json"}, nil) + + require.Error(t, err) + require.Contains(t, stdout, `"asset": "8.8.8.8"`) + require.Contains(t, stdout, `"asset": "1.1.1.1"`) + require.Contains(t, stdout, `"unassigned": false`) + }) +} + +func TestTagsUnassignCommand_Bulk(t *testing.T) { + testCases := []struct { + name string + args []string + stdin io.Reader + seams unassignSeams + service func(t *testing.T, ctrl *gomock.Controller) apptags.Service + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "--all with positional assets is a mode conflict", + args: []string{"alpha", "8.8.8.8", "--all"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be combined with explicit assets") + }, + }, + { + name: "--all with --input-file is a mode conflict", + args: []string{"alpha", "--input-file", "-", "--all"}, + stdin: bytes.NewBufferString("8.8.8.8\n"), + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be combined with explicit assets") + }, + }, + { + name: "a time filter with positional assets is a mode conflict", + args: []string{"alpha", "8.8.8.8", "--created-before", "2026-01-01T00:00:00Z"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be combined with explicit assets") + }, + }, + { + // --all already means every assignment, so narrowing it is contradictory. + name: "--all with --created-before is rejected", + args: []string{"alpha", "--all", "--created-before", "2026-01-01T00:00:00Z"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--all unassigns every assignment") + }, + }, + { + name: "--all with --created-after is rejected", + args: []string{"alpha", "--all", "--created-after", "2026-01-01T00:00:00Z"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--all unassigns every assignment") + }, + }, + { + // Caught in PreRun, so it fails without needing credentials; the + // service keeps its own guard for callers that skip the command layer. + name: "an inverted time window is rejected before the service", + args: []string{ + "alpha", + "--created-before", "2020-01-01T00:00:00Z", + "--created-after", "2026-01-01T00:00:00Z", + "--yes", + }, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "created-before must be after created-after") + }, + }, + { + name: "--wait without a bulk mode flag is rejected", + args: []string{"alpha", "8.8.8.8", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--wait only applies to a bulk unassignment") + }, + }, + { + name: "--timeout without --wait is rejected", + args: []string{"alpha", "--all", "--timeout", "5m"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--timeout only applies while polling") + }, + }, + { + name: "non-interactive without --yes refuses to submit", + args: []string{"alpha", "--all"}, + seams: unassignSeams{stdinIsTTY: func() bool { return false }}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "confirmation required") + }, + }, + { + name: "declining the prompt aborts without submitting", + args: []string{"alpha", "--all"}, + seams: unassignSeams{ + stdinIsTTY: alwaysTTY(), + confirm: func(context.Context, string) (bool, error) { return false, nil }, + }, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, "Unassign aborted.") + }, + }, + { + name: "--all --yes submits an unfiltered removal and reports a track hint", + args: []string{"alpha", "--all", "--yes"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Times(0) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkUnassignParams) (apptags.BulkUnassignResult, cenclierrors.CencliError) { + require.Equal(t, "alpha", p.TagID.String()) + // --all must not smuggle a time filter into the request. + require.False(t, p.CreatedBefore.IsPresent()) + require.False(t, p.CreatedAfter.IsPresent()) + return bulkUnassignSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Tag Operation") + require.Contains(t, stdout, testOperationID) + require.Contains(t, stderr, "Track with: censys tags operations get alpha "+testOperationID) + require.Contains(t, stderr, "few minutes") + }, + }, + { + name: "a time filter alone selects bulk mode and is threaded to the service", + args: []string{"alpha", "--created-before", "2026-01-01T00:00:00Z", "--yes"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkUnassignParams) (apptags.BulkUnassignResult, cenclierrors.CencliError) { + require.True(t, p.CreatedBefore.IsPresent()) + require.Equal(t, + time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + p.CreatedBefore.MustGet().UTC()) + require.False(t, p.CreatedAfter.IsPresent()) + return bulkUnassignSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "both time filters bound the window", + args: []string{ + "alpha", + "--created-after", "2026-01-01T00:00:00Z", + "--created-before", "2026-06-01T00:00:00Z", + "--yes", + }, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkUnassignParams) (apptags.BulkUnassignResult, cenclierrors.CencliError) { + require.True(t, p.CreatedBefore.IsPresent()) + require.True(t, p.CreatedAfter.IsPresent()) + return bulkUnassignSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "org id is threaded to the service", + args: []string{"alpha", "--all", "--yes", "--org-id", "11111111-1111-1111-1111-111111111111"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.BulkUnassignParams) (apptags.BulkUnassignResult, cenclierrors.CencliError) { + require.True(t, p.OrgID.IsPresent()) + return bulkUnassignSubmitted("pending"), nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "json output renders the operation payload", + args: []string{"alpha", "--all", "--yes", "--output-format", "json"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignSubmitOnly(ctrl, "pending") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"status": "pending"`) + require.Contains(t, stdout, `"type": "bulk_delete"`) + }, + }, + { + name: "--wait polls the submitted operation and renders the final status", + args: []string{"alpha", "--all", "--yes", "--wait"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return(bulkUnassignSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.WaitParams) (apptags.GetOperationResult, cenclierrors.CencliError) { + // The wait must follow the operation the submit just created. + require.Equal(t, testOperationID, p.OperationID) + require.True(t, p.Timeout.IsPresent()) + return apptags.GetOperationResult{ + Meta: okMeta(), Operation: finishedDeleteOperation("succeeded"), + }, nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "succeeded") + // Waiting to the end replaces the hint with the outcome. + require.NotContains(t, stderr, "Track with") + }, + }, + { + name: "--wait ending at the asset limit warns but succeeds", + args: []string{"alpha", "--all", "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignSubmitAndWait(ctrl, "limit_reached") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, "asset limit") + require.Contains(t, stderr, "few minutes") + }, + }, + { + name: "--wait ending failed exits non-zero", + args: []string{"alpha", "--all", "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignSubmitAndWait(ctrl, "failed") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + // The payload still renders; only the exit code reports the outcome. + require.Contains(t, stdout, "failed") + }, + }, + { + name: "--wait ending cancelled exits non-zero", + args: []string{"alpha", "--all", "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignSubmitAndWait(ctrl, "cancelled") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stdout, "cancelled") + }, + }, + { + name: "interrupting the wait keeps the job and prints how to follow it", + args: []string{"alpha", "--all", "--yes", "--wait"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return(bulkUnassignSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{}, cenclierrors.NewInterruptedError()) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stderr, "continues server-side") + require.Contains(t, stderr, "Track with: censys tags operations get alpha "+testOperationID) + }, + }, + { + name: "a wait that times out still points at the running job", + args: []string{"alpha", "--all", "--yes", "--wait", "--timeout", "5s"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return(bulkUnassignSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).Return( + apptags.GetOperationResult{}, + apptags.NewOperationWaitTimeoutError(testOperationID, "running", 5*time.Second)) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, stderr, "Track with: censys tags operations get alpha "+testOperationID) + }, + }, + { + name: "--timeout 0 waits without a limit", + args: []string{"alpha", "--all", "--yes", "--wait", "--timeout", "0"}, + service: func(t *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return(bulkUnassignSubmitted("pending"), nil) + m.EXPECT().WaitForOperation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.WaitParams) (apptags.GetOperationResult, cenclierrors.CencliError) { + // Zero means unbounded, not "give up before the first poll". + require.False(t, p.Timeout.IsPresent()) + return apptags.GetOperationResult{ + Meta: okMeta(), Operation: finishedDeleteOperation("succeeded"), + }, nil + }) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + }, + }, + { + name: "a negative --timeout is rejected", + args: []string{"alpha", "--all", "--yes", "--wait", "--timeout", "-5m"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignNoCallService(ctrl) + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "--timeout must not be negative") + }, + }, + { + name: "--quiet suppresses the hint and the index-lag note", + args: []string{"alpha", "--all", "--yes"}, + seams: unassignSeams{quiet: true}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignSubmitOnly(ctrl, "pending") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Empty(t, stderr) + // The operation itself is the result, so it still renders. + require.Contains(t, stdout, testOperationID) + }, + }, + { + name: "a tag name needing quoting is safe to paste back", + args: []string{"my tag", "--all", "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + return bulkUnassignSubmitOnly(ctrl, "pending") + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stderr, `operations get "my tag" `+testOperationID) + }, + }, + { + name: "a failed submit reports the error and nothing to track", + args: []string{"alpha", "--all", "--yes"}, + service: func(_ *testing.T, ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().BulkUnassign(gomock.Any(), gomock.Any()).Return( + apptags.BulkUnassignResult{}, cenclierrors.NewCencliError(errors.New("Permission denied"))) + return m + }, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.NotContains(t, stderr, "Track with") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Bulk always confirms, so default to an accepted prompt on a TTY. + seams := tc.seams + if seams.stdinIsTTY == nil { + seams.stdinIsTTY = alwaysTTY() + } + if seams.confirm == nil { + seams.confirm = func(context.Context, string) (bool, error) { return true, nil } + } + + stdout, stderr, err := runUnassignCommand(t, tc.service(t, ctrl), seams, tc.args, tc.stdin) + tc.assert(t, stdout, stderr, err) + }) + } +} + +// TestTagsUnassignCommand_BulkConfirmationMessage pins what the prompt tells the +// user before they approve a removal they cannot undo. The difference between +// wiping a tag and trimming a window has to be visible in the prompt itself. +func TestTagsUnassignCommand_BulkConfirmationMessage(t *testing.T) { + testCases := []struct { + name string + args []string + contains []string + }{ + { + name: "--all names every asset", + args: []string{"alpha", "--all"}, + contains: []string{`"alpha"`, "ALL assigned assets"}, + }, + { + name: "created-before names the upper bound", + args: []string{"alpha", "--created-before", "2026-01-01T00:00:00Z"}, + contains: []string{"created before", "2026-01-01"}, + }, + { + name: "created-after names the lower bound", + args: []string{"alpha", "--created-after", "2026-01-01T00:00:00Z"}, + contains: []string{"created after", "2026-01-01"}, + }, + { + name: "both filters name the window", + args: []string{ + "alpha", + "--created-after", "2026-01-01T00:00:00Z", + "--created-before", "2026-06-01T00:00:00Z", + }, + contains: []string{"created between", "2026-01-01", "2026-06-01"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var prompt string + seams := unassignSeams{ + stdinIsTTY: alwaysTTY(), + confirm: func(_ context.Context, message string) (bool, error) { + prompt = message + // Declining keeps the test off the submit path. + return false, nil + }, + } + + _, _, err := runUnassignCommand(t, bulkUnassignNoCallService(ctrl), seams, tc.args, nil) + require.NoError(t, err) + for _, want := range tc.contains { + require.Contains(t, prompt, want) + } + }) + } +} + +func TestTagsUnassignCommand_InputFile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dir := t.TempDir() + file := filepath.Join(dir, "assets.txt") + require.NoError(t, os.WriteFile(file, []byte("8.8.8.8\n1.1.1.1\n"), 0o600)) + + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().Unassign(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p apptags.UnassignParams) (apptags.UnassignResult, cenclierrors.CencliError) { + require.Equal(t, []string{"8.8.8.8", "1.1.1.1"}, p.AssetIDs) + return unassignResult("alpha", p.AssetIDs, nil), nil + }) + + _, _, err := runUnassignCommand(t, m, noPromptSeams(t), []string{"alpha", "--input-file", file}, nil) + require.NoError(t, err) +} diff --git a/internal/command/tags/update.go b/internal/command/tags/update.go new file mode 100644 index 0000000..e04eb42 --- /dev/null +++ b/internal/command/tags/update.go @@ -0,0 +1,190 @@ +package tags + +import ( + "context" + "fmt" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" +) + +const updateCmdName = "update" + +// UpdateCommand implements `tags update `, mutating an existing tag by name or UUID. +type UpdateCommand struct { + *command.BaseCommand + // services the command uses + tagsSvc tags.Service + // flags the command uses + flags updateCommandFlags + // state - populated by PreRun + orgID mo.Option[identifiers.OrganizationID] + tagID identifiers.TagID + name mo.Option[string] + privacy mo.Option[string] + description mo.Option[string] + // result stores the updated tag for rendering + result tags.UpdateResult +} + +type updateCommandFlags struct { + orgID flags.OrgIDFlag + name flags.StringFlag + privacy flags.StringFlag + description flags.StringFlag + clearDescription flags.BoolFlag +} + +var _ command.Command = (*UpdateCommand)(nil) + +func NewUpdateCommand(cmdContext *command.Context) *UpdateCommand { + return &UpdateCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *UpdateCommand) Use() string { + return fmt.Sprintf("%s ", updateCmdName) +} + +func (c *UpdateCommand) Short() string { + return "Update an existing tag" +} + +func (c *UpdateCommand) Long() string { + return `Update an existing tag by its name or UUID. + +At least one mutation flag is required. Use --clear-description to remove a tag's description; it cannot be combined with --description.` +} + +func (c *UpdateCommand) Examples() []string { + return []string{ + `my-tag --description "Assets flagged for review" # Set a description`, + "my-tag --privacy shared # Make a tag visible to the organization", + "my-tag --name renamed-tag # Rename a tag", + "my-tag --clear-description # Remove the description", + } +} + +func (c *UpdateCommand) Args() command.PositionalArgs { + return command.ExactArgs(1) +} + +func (c *UpdateCommand) DefaultOutputType() command.OutputType { + return command.OutputTypeShort +} + +func (c *UpdateCommand) SupportedOutputTypes() []command.OutputType { + return []command.OutputType{command.OutputTypeShort, command.OutputTypeData} +} + +func (c *UpdateCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.name = flags.NewStringFlag(c.Flags(), false, "name", "", "", "a new name for the tag") + c.flags.privacy = flags.NewStringFlag(c.Flags(), false, "privacy", "", "", "tag visibility (private, shared)") + c.flags.description = flags.NewStringFlag(c.Flags(), false, "description", "", "", "a new description for the tag") + c.flags.clearDescription = flags.NewBoolFlag(c.Flags(), "clear-description", "", false, "remove the tag's description") + return nil +} + +func (c *UpdateCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + c.tagID, err = requireTagID(args[0]) + if err != nil { + return err + } + + name, err := c.flags.name.Value() + if err != nil { + return err + } + c.name = optionalNonEmpty(name) + + privacy, err := c.flags.privacy.Value() + if err != nil { + return err + } + c.privacy = optionalNonEmpty(privacy) + + description, err := c.flags.description.Value() + if err != nil { + return err + } + clearDescription, err := c.flags.clearDescription.Value() + if err != nil { + return err + } + if description != "" && clearDescription { + return NewDescriptionConflictError() + } + if clearDescription { + c.description = mo.Some("") + } else { + c.description = optionalNonEmpty(description) + } + + if !c.name.IsPresent() && !c.privacy.IsPresent() && !c.description.IsPresent() { + return NewNothingToUpdateError() + } + + return c.resolveTagsService() +} + +func (c *UpdateCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(cmdName).With( + "orgID_set", c.orgID.IsPresent(), + "tagID_is_uuid", c.tagID.UID().IsPresent(), + "name_set", c.name.IsPresent(), + "privacy_set", c.privacy.IsPresent(), + "description_set", c.description.IsPresent(), + ) + + err := c.WithProgress( + cmd.Context(), + logger, + "Updating tag...", + func(pctx context.Context) cenclierrors.CencliError { + var updateErr cenclierrors.CencliError + c.result, updateErr = c.tagsSvc.UpdateTag(pctx, tags.UpdateParams{ + OrgID: c.orgID, + TagID: c.tagID, + Name: c.name, + Description: c.description, + Privacy: c.privacy, + }) + return updateErr + }, + ) + if err != nil { + logger.Debug("update tag failed", "error", err) + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + + return c.PrintData(c, c.result.Tag) +} + +func (c *UpdateCommand) resolveTagsService() cenclierrors.CencliError { + svc, err := c.TagsService() + if err != nil { + return err + } + c.tagsSvc = svc + return nil +} + +// RenderShort renders the updated tag as a labeled detail view (TTY-aware). +func (c *UpdateCommand) RenderShort() cenclierrors.CencliError { + return renderTagDetail("━━━ Tag Updated ━━━", c.result.Tag) +} diff --git a/internal/command/tags/update_test.go b/internal/command/tags/update_test.go new file mode 100644 index 0000000..71d86d6 --- /dev/null +++ b/internal/command/tags/update_test.go @@ -0,0 +1,199 @@ +package tags + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + tagsmocks "github.com/censys/cencli/gen/app/tags/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + apptags "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" +) + +func runUpdateCommand(t *testing.T, svc apptags.Service, args []string) (stdout, stderr string, err error) { + t.Helper() + + tempDir := t.TempDir() + viper.Reset() + cfg, cfgErr := config.New(tempDir) + require.NoError(t, cfgErr) + + var outBuf, errBuf bytes.Buffer + formatter.Stdout = &outBuf + formatter.Stderr = &errBuf + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := storemocks.NewMockStore(ctrl) + cmdContext := command.NewCommandContext(cfg, mockStore, command.WithTagsService(svc)) + rootCmd, buildErr := command.RootCommandToCobra(NewUpdateCommand(cmdContext)) + require.NoError(t, buildErr) + + rootCmd.SetArgs(args) + cmdErr := rootCmd.Execute() + return outBuf.String(), errBuf.String(), cmdErr +} + +func TestTagsUpdateCommand(t *testing.T) { + testCases := []struct { + name string + service func(ctrl *gomock.Controller) apptags.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + { + name: "success - short output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), gomock.Any()).Return( + apptags.UpdateResult{Meta: okMeta(), Tag: tag("alpha")}, + nil, + ) + return m + }, + args: []string{"alpha", "--privacy", "shared"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "alpha") + require.Contains(t, stdout, "Name:") + require.Contains(t, stdout, "Tag Updated") + }, + }, + { + name: "success - json output", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), gomock.Any()).Return( + apptags.UpdateResult{Meta: okMeta(), Tag: tag("alpha")}, + nil, + ) + return m + }, + args: []string{"alpha", "--name", "renamed", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"name": "alpha"`) + }, + }, + { + name: "mutation flags threaded to service; tag id passed raw", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.UpdateParams) (apptags.UpdateResult, cenclierrors.CencliError) { + require.Equal(t, "my-tag", params.TagID.String()) + require.Equal(t, "renamed", params.Name.MustGet()) + require.Equal(t, "shared", params.Privacy.MustGet()) + require.Equal(t, "some notes", params.Description.MustGet()) + return apptags.UpdateResult{Meta: okMeta(), Tag: tag("renamed")}, nil + }, + ) + return m + }, + args: []string{"my-tag", "--name", "renamed", "--privacy", "shared", "--description", "some notes"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "renamed") + }, + }, + { + name: "clear-description sends an empty description", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params apptags.UpdateParams) (apptags.UpdateResult, cenclierrors.CencliError) { + require.True(t, params.Description.IsPresent()) + require.Equal(t, "", params.Description.MustGet()) + require.True(t, params.Name.IsAbsent()) + require.True(t, params.Privacy.IsAbsent()) + return apptags.UpdateResult{Meta: okMeta(), Tag: tag("my-tag")}, nil + }, + ) + return m + }, + args: []string{"my-tag", "--clear-description"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "my-tag") + }, + }, + { + name: "error - nothing to update", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"my-tag"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no fields to update") + }, + }, + { + name: "error - description conflict", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{"my-tag", "--description", "foo", "--clear-description"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be used together") + }, + }, + { + name: "error - missing arg", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: nil, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + }, + }, + { + name: "error - empty tag id", + service: func(ctrl *gomock.Controller) apptags.Service { + return tagsmocks.NewMockTagsService(ctrl) // not called + }, + args: []string{" ", "--privacy", "shared"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "required") + }, + }, + { + name: "error - service failure surfaced", + service: func(ctrl *gomock.Controller) apptags.Service { + m := tagsmocks.NewMockTagsService(ctrl) + m.EXPECT().UpdateTag(gomock.Any(), gomock.Any()).Return( + apptags.UpdateResult{}, + cenclierrors.NewCencliError(errors.New("tag not found")), + ) + return m + }, + args: []string{"missing", "--privacy", "shared"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "tag not found") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stdout, stderr, err := runUpdateCommand(t, tc.service(ctrl), tc.args) + tc.assert(t, stdout, stderr, err) + }) + } +} diff --git a/internal/command/tags/utils.go b/internal/command/tags/utils.go new file mode 100644 index 0000000..5842afb --- /dev/null +++ b/internal/command/tags/utils.go @@ -0,0 +1,194 @@ +package tags + +import ( + "context" + "errors" + "log/slog" + "strings" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/tags" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/assets" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/input" + "github.com/censys/cencli/internal/pkg/styles" + "github.com/censys/cencli/internal/pkg/ui/form" +) + +// requireTagID builds a TagID from a positional argument and rejects an empty +// (or whitespace-only) identifier, which no tag command can act on. +func requireTagID(raw string) (identifiers.TagID, cenclierrors.CencliError) { + id := identifiers.NewTagID(raw) + if id.String() == "" { + return id, tags.NewEmptyTagIDError() + } + return id, nil +} + +// requireOperationID validates a positional operation identifier. The endpoint +// declares operation_id as a UUID, so anything else is rejected here rather than +// spent on a request that could only come back as a 422. +func requireOperationID(raw string) (string, cenclierrors.CencliError) { + trimmed := strings.TrimSpace(raw) + if _, err := uuid.Parse(trimmed); err != nil { + return "", tags.NewInvalidOperationIDError(trimmed) + } + return trimmed, nil +} + +// parsePaginationFlags reads the shared --page-size/--max-pages pair. The +// max-pages flag is built without a lower bound so the -1 "all pages" sentinel +// gets through, which leaves rejecting 0 and negatives to this switch. +func parsePaginationFlags( + pageSizeFlag, maxPagesFlag flags.IntegerFlag, +) (pageSize, maxPages mo.Option[uint64], err cenclierrors.CencliError) { + rawPageSize, err := pageSizeFlag.Value() + if err != nil { + return pageSize, maxPages, err + } + if rawPageSize.IsPresent() { + pageSize = mo.Some(uint64(rawPageSize.MustGet())) + } + + rawMaxPages, err := maxPagesFlag.Value() + if err != nil { + return pageSize, maxPages, err + } + if rawMaxPages.IsPresent() { + switch v := rawMaxPages.MustGet(); { + case v == -1: + maxPages = mo.None[uint64]() + case v <= 0: + return pageSize, maxPages, flags.NewIntegerFlagInvalidValueError("max-pages", v, "must be -1 or >= 1") + default: + maxPages = mo.Some(uint64(v)) + } + } + + return pageSize, maxPages, nil +} + +// warnFetchingAllPages tells the user that --max-pages=-1 will keep requesting +// pages until the server runs out, since the call count is otherwise invisible +// until it is spent. Mirrors the same warning on search. +func warnFetchingAllPages(quiet bool, logger *slog.Logger, maxPages mo.Option[uint64]) { + if quiet || maxPages.IsPresent() { + return + } + msg := styles.GlobalStyles.Warning.Render( + "Warning: fetching all pages (--max-pages=-1). This may take a while and increase API usage.") + formatter.Println(formatter.Stderr, msg) + logger.Debug("fetching all pages", "message", msg) +} + +// uuidFilterString renders an optional UUID filter as the string the service +// layer threads through, leaving an absent filter absent. +func uuidFilterString(v mo.Option[uuid.UUID]) mo.Option[string] { + if !v.IsPresent() { + return mo.None[string]() + } + return mo.Some(v.MustGet().String()) +} + +// optionalNonEmpty treats a blank flag value as "filter not provided", so it is +// omitted from the request rather than sent as an empty filter. +func optionalNonEmpty(v string) mo.Option[string] { + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return mo.None[string]() + } + return mo.Some(trimmed) +} + +// printNote writes an advisory line to stderr unless --quiet asked for silence. +// Outcome messages (an abort, an error) are not notes and always print. +func printNote(quiet bool, message string) { + if quiet { + return + } + formatter.Println(formatter.Stderr, message) +} + +// confirmAction asks the user to approve a destructive action, translating an +// aborted prompt into the repo's interrupted error. A false answer means the +// caller should stop without treating it as a failure. +func confirmAction( + ctx context.Context, + confirm func(ctx context.Context, message string) (bool, error), + message string, +) (bool, cenclierrors.CencliError) { + confirmed, err := confirm(ctx, message) + if err != nil { + if errors.Is(err, form.ErrUserAborted) { + return false, cenclierrors.NewInterruptedError() + } + return false, cenclierrors.NewCencliError(err) + } + return confirmed, nil +} + +// gatherAssetIDs collects the assets an assign/unassign command should act on: +// from --input-file when set, otherwise the positional args after the tag (each +// comma-split). +func gatherAssetIDs(cmd *cobra.Command, inputFile flags.FileFlag, args []string) ([]string, cenclierrors.CencliError) { + var raw []string + if inputFile.IsSet() { + lines, err := inputFile.Lines(cmd) + if err != nil { + return nil, err + } + raw = lines + } else { + assetArgs := args[1:] + if len(assetArgs) == 0 { + return nil, assets.NewNoAssetsError() + } + for _, a := range assetArgs { + raw = append(raw, input.SplitString(a)...) + } + } + + return classifyAssetIDs(raw) +} + +// assetTypesByID maps each already-validated asset ID to its type, so a failed +// asset can still report one - the API only echoes a type back for assets it +// accepted. The values use the API's vocabulary, since successful rows in the +// same table are labeled by it: the domain says "webproperty", the API +// "web_property". +func assetTypesByID(ids []string) map[string]string { + classifier := assets.NewAssetClassifier(ids...) + types := make(map[string]string, len(ids)) + + for _, h := range classifier.HostIDs() { + types[h.String()] = "host" + } + for _, c := range classifier.CertificateIDs() { + types[c.String()] = "certificate" + } + for _, w := range classifier.WebPropertyIDs() { + types[w.String()] = "web_property" + } + return types +} + +// classifyAssetIDs validates raw asset inputs and returns their normalized IDs. +// Mixed asset types are allowed — every caller acts on one asset per request, so +// AssetType() is never consulted and only unparseable inputs are rejected. +func classifyAssetIDs(raw []string) ([]string, cenclierrors.CencliError) { + classifier := assets.NewAssetClassifier(raw...) + if unknown := classifier.UnknownAssets(); len(unknown) > 0 { + return nil, assets.NewInvalidAssetIDError(unknown[0], "unable to infer asset type") + } + ids := classifier.KnownAssetIDs() + if len(ids) == 0 { + return nil, assets.NewNoAssetsError() + } + return ids, nil +} diff --git a/internal/pkg/clients/censys/censys.go b/internal/pkg/clients/censys/censys.go index 6bc3694..e3a5497 100644 --- a/internal/pkg/clients/censys/censys.go +++ b/internal/pkg/clients/censys/censys.go @@ -28,6 +28,7 @@ type Client interface { CollectionsClient ThreatHuntingClient AccountManagementClient + TagsClient HasOrgID() bool // CredentialInfo describes the credential authenticating requests. CredentialInfo() credential.Info @@ -57,6 +58,7 @@ type censysSDKImpl struct { CollectionsClient ThreatHuntingClient AccountManagementClient + TagsClient } var _ Client = &censysSDKImpl{} @@ -133,6 +135,7 @@ func NewCensysSDK( CollectionsClient: newCollectionsSDK(censysSDK), ThreatHuntingClient: newThreatHuntingSDK(censysSDK), AccountManagementClient: newAccountManagementSDK(censysSDK), + TagsClient: newTagsSDK(censysSDK), }, nil } diff --git a/internal/pkg/clients/censys/errors.go b/internal/pkg/clients/censys/errors.go index c94eecf..2b63515 100644 --- a/internal/pkg/clients/censys/errors.go +++ b/internal/pkg/clients/censys/errors.go @@ -146,6 +146,23 @@ func (e *censysClientError) Title() string { return "Error Returned from Censys API" } +// Detail returns the API's one-line summary of the failure. Error() renders the +// whole problem document, which is right for a standalone error but too much for +// a per-item result, so callers reporting many failures should prefer this. +func (e *censysClientError) Detail() mo.Option[string] { + if e.detail.IsPresent() { + return e.detail + } + // Some responses carry only per-field errors; the first still beats the + // full document. + for _, ed := range e.errors { + if ed.message.IsPresent() { + return ed.message + } + } + return e.title +} + func (e *censysClientError) ShouldPrintUsage() bool { return false } diff --git a/internal/pkg/clients/censys/tags.go b/internal/pkg/clients/censys/tags.go new file mode 100644 index 0000000..3a0fbf2 --- /dev/null +++ b/internal/pkg/clients/censys/tags.go @@ -0,0 +1,627 @@ +package censys + +import ( + "context" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/operations" + "github.com/samber/mo" +) + +// ListTagsRequest bundles the query parameters for ListTags. +type ListTagsRequest struct { + OrgID mo.Option[string] + PageSize mo.Option[int64] + PageToken mo.Option[string] + OrderBy mo.Option[string] + Name mo.Option[string] + CreatedBy mo.Option[string] + Privacy mo.Option[string] +} + +// CreateTagRequest bundles the fields for CreateTag. +type CreateTagRequest struct { + OrgID mo.Option[string] + Name string + Description mo.Option[string] + Privacy string +} + +// UpdateTagRequest bundles the fields for UpdateTag. Fields are optional; only +// present options are sent. +type UpdateTagRequest struct { + OrgID mo.Option[string] + TagID string + Name mo.Option[string] + Description mo.Option[string] + Privacy mo.Option[string] +} + +// CreateTagAssignmentRequest bundles the fields for CreateTagAssignment. TagID +// is the resolved tag UUID. +type CreateTagAssignmentRequest struct { + OrgID mo.Option[string] + TagID string + AssetID string +} + +// ListTagAssignmentsRequest bundles the query parameters for ListTagAssignments. +// TagID is the resolved tag UUID; only present options are sent. +type ListTagAssignmentsRequest struct { + OrgID mo.Option[string] + TagID string + AssetID mo.Option[string] + AssetType mo.Option[string] + CreatedBy mo.Option[string] + CreatedBefore mo.Option[time.Time] + CreatedAfter mo.Option[time.Time] + OrderBy mo.Option[string] + PageSize mo.Option[int64] + PageToken mo.Option[string] +} + +// BulkCreateTagAssignmentsRequest bundles the fields for +// BulkCreateTagAssignments. TagID is the resolved tag UUID; an absent MaxAssets +// leaves the cap to the plan's tag asset limit. +type BulkCreateTagAssignmentsRequest struct { + OrgID mo.Option[string] + TagID string + Query string + MaxAssets mo.Option[int64] +} + +// BulkDeleteTagAssignmentsRequest bundles the fields for +// BulkDeleteTagAssignments. TagID is the resolved tag UUID; with neither +// timestamp present the endpoint removes every assignment of the tag. +type BulkDeleteTagAssignmentsRequest struct { + OrgID mo.Option[string] + TagID string + CreatedBefore mo.Option[time.Time] + CreatedAfter mo.Option[time.Time] +} + +// ListTagOperationsRequest bundles the query parameters for ListTagOperations. +// TagID is a resolved tag UUID, or "-" to list operations across every tag in +// the organization; only present options are sent. +type ListTagOperationsRequest struct { + OrgID mo.Option[string] + TagID string + Type mo.Option[string] + Status mo.Option[string] + OrderBy mo.Option[string] + PageSize mo.Option[int64] + PageToken mo.Option[string] +} + +//go:generate mockgen -destination=../../../../gen/client/mocks/tags_mock.go -package=mocks github.com/censys/cencli/internal/pkg/clients/censys TagsClient +type TagsClient interface { + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#listtags + ListTags(ctx context.Context, req ListTagsRequest) (Result[components.TagsList], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#gettag + GetTag( + ctx context.Context, + orgID mo.Option[string], + tagID string, + ) (Result[components.Tag], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#createtag + CreateTag(ctx context.Context, req CreateTagRequest) (Result[components.Tag], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#updatetag + UpdateTag(ctx context.Context, req UpdateTagRequest) (Result[components.Tag], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#deletetag + // + // DeleteTag returns only response metadata; the endpoint has no body. + DeleteTag(ctx context.Context, orgID mo.Option[string], tagID string) (Metadata, ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#createtagassignment + CreateTagAssignment(ctx context.Context, req CreateTagAssignmentRequest) (Result[components.TagAssignment], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#bulkcreatetagassignments + // + // BulkCreateTagAssignments starts an asynchronous job and returns the + // operation tracking it, not the assignments themselves. + BulkCreateTagAssignments(ctx context.Context, req BulkCreateTagAssignmentsRequest) (Result[components.TagOperation], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#bulkdeletetagassignments + // + // BulkDeleteTagAssignments starts an asynchronous job and returns the + // operation tracking it, not the removed assignments. + BulkDeleteTagAssignments(ctx context.Context, req BulkDeleteTagAssignmentsRequest) (Result[components.TagOperation], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#listtagassignments + ListTagAssignments(ctx context.Context, req ListTagAssignmentsRequest) (Result[components.TagAssignmentsList], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#deletetagassignment + // + // DeleteTagAssignment returns only response metadata; the endpoint has no body. + DeleteTagAssignment(ctx context.Context, orgID mo.Option[string], tagID, assignmentID string) (Metadata, ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#listtagoperations + ListTagOperations(ctx context.Context, req ListTagOperationsRequest) (Result[components.TagOperationsList], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#gettagoperation + GetTagOperation( + ctx context.Context, + orgID mo.Option[string], + tagID, operationID string, + ) (Result[components.TagOperation], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/tagsandcomments#canceltagoperation + // + // CancelTagOperation requests cancellation and returns the operation as it + // stood when the request was accepted; the job may still be winding down. + CancelTagOperation( + ctx context.Context, + orgID mo.Option[string], + tagID, operationID string, + ) (Result[components.TagOperation], ClientError) +} + +type tagsSDK struct { + *censysSDK +} + +var _ TagsClient = &tagsSDK{} + +func newTagsSDK(censysSDK *censysSDK) *tagsSDK { + return &tagsSDK{ + censysSDK: censysSDK, + } +} + +func (t *tagsSDK) ListTags( + ctx context.Context, + req ListTagsRequest, +) (Result[components.TagsList], ClientError) { + start := time.Now() + var res *operations.V3TagsListTagsResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsListTagsRequest{ + OrganizationID: req.OrgID.ToPointer(), + PageToken: req.PageToken.ToPointer(), + Name: req.Name.ToPointer(), + CreatedBy: req.CreatedBy.ToPointer(), + } + if req.PageSize.IsPresent() { + ps := int(req.PageSize.MustGet()) + sdkReq.PageSize = &ps + } + if req.OrderBy.IsPresent() { + ob := operations.V3TagsListTagsQueryParamOrderBy(req.OrderBy.MustGet()) + sdkReq.OrderBy = &ob + } + if req.Privacy.IsPresent() { + p := operations.Privacy(req.Privacy.MustGet()) + sdkReq.Privacy = &p + } + res, err = t.censysSDK.client.TagsAndComments.ListTags(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagsList]{} + return zero, err + } + tagsList := res.GetResponseEnvelopeTagsList().GetResult() + return Result[components.TagsList]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: tagsList, + }, nil +} + +func (t *tagsSDK) GetTag( + ctx context.Context, + orgID mo.Option[string], + tagID string, +) (Result[components.Tag], ClientError) { + start := time.Now() + var res *operations.V3TagsGetTagResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + req := operations.V3TagsGetTagRequest{ + OrganizationID: orgID.ToPointer(), + TagID: tagID, + } + res, err = t.censysSDK.client.TagsAndComments.GetTag(ctx, req) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.Tag]{} + return zero, err + } + tag := res.GetResponseEnvelopeTag().GetResult() + return Result[components.Tag]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: tag, + }, nil +} + +func (t *tagsSDK) ListTagOperations( + ctx context.Context, + req ListTagOperationsRequest, +) (Result[components.TagOperationsList], ClientError) { + start := time.Now() + var res *operations.V3TagsListOperationsResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsListOperationsRequest{ + OrganizationID: req.OrgID.ToPointer(), + TagID: req.TagID, + PageToken: req.PageToken.ToPointer(), + } + if req.PageSize.IsPresent() { + ps := int(req.PageSize.MustGet()) + sdkReq.PageSize = &ps + } + // The SDK names the operation filter enums bare Type and Status; they are + // distinct from the components.TagOperation* enums on the response. + if req.Type.IsPresent() { + ty := operations.Type(req.Type.MustGet()) + sdkReq.Type = &ty + } + if req.Status.IsPresent() { + st := operations.Status(req.Status.MustGet()) + sdkReq.Status = &st + } + if req.OrderBy.IsPresent() { + ob := operations.V3TagsListOperationsQueryParamOrderBy(req.OrderBy.MustGet()) + sdkReq.OrderBy = &ob + } + res, err = t.censysSDK.client.TagsAndComments.ListTagOperations(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagOperationsList]{} + return zero, err + } + opsList := res.GetResponseEnvelopeTagOperationsList().GetResult() + return Result[components.TagOperationsList]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: opsList, + }, nil +} + +func (t *tagsSDK) GetTagOperation( + ctx context.Context, + orgID mo.Option[string], + tagID, operationID string, +) (Result[components.TagOperation], ClientError) { + start := time.Now() + var res *operations.V3TagsGetOperationResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + req := operations.V3TagsGetOperationRequest{ + OrganizationID: orgID.ToPointer(), + TagID: tagID, + OperationID: operationID, + } + res, err = t.censysSDK.client.TagsAndComments.GetTagOperation(ctx, req) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagOperation]{} + return zero, err + } + op := res.GetResponseEnvelopeTagOperation().GetResult() + return Result[components.TagOperation]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: op, + }, nil +} + +func (t *tagsSDK) CancelTagOperation( + ctx context.Context, + orgID mo.Option[string], + tagID, operationID string, +) (Result[components.TagOperation], ClientError) { + start := time.Now() + var res *operations.V3TagsCancelOperationResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + req := operations.V3TagsCancelOperationRequest{ + OrganizationID: orgID.ToPointer(), + TagID: tagID, + OperationID: operationID, + } + res, err = t.censysSDK.client.TagsAndComments.CancelTagOperation(ctx, req) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagOperation]{} + return zero, err + } + op := res.GetResponseEnvelopeTagOperation().GetResult() + return Result[components.TagOperation]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: op, + }, nil +} + +func (t *tagsSDK) DeleteTag( + ctx context.Context, + orgID mo.Option[string], + tagID string, +) (Metadata, ClientError) { + start := time.Now() + var res *operations.V3TagsDeleteTagResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + req := operations.V3TagsDeleteTagRequest{ + OrganizationID: orgID.ToPointer(), + TagID: tagID, + } + res, err = t.censysSDK.client.TagsAndComments.DeleteTag(ctx, req) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + return Metadata{}, err + } + // The delete endpoint returns no body, only response metadata. + return buildResponseMetadata(res, latency, attempts), nil +} + +func (t *tagsSDK) CreateTag( + ctx context.Context, + req CreateTagRequest, +) (Result[components.Tag], ClientError) { + start := time.Now() + var res *operations.V3TagsCreateTagResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsCreateTagRequest{ + OrganizationID: req.OrgID.ToPointer(), + CreateTagInputBody: components.CreateTagInputBody{ + Name: req.Name, + Description: req.Description.ToPointer(), + Privacy: components.CreateTagInputBodyPrivacy(req.Privacy), + }, + } + res, err = t.censysSDK.client.TagsAndComments.CreateTag(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.Tag]{} + return zero, err + } + tag := res.GetResponseEnvelopeTag().GetResult() + return Result[components.Tag]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: tag, + }, nil +} + +func (t *tagsSDK) CreateTagAssignment( + ctx context.Context, + req CreateTagAssignmentRequest, +) (Result[components.TagAssignment], ClientError) { + start := time.Now() + var res *operations.V3TagsCreateAssignmentResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsCreateAssignmentRequest{ + OrganizationID: req.OrgID.ToPointer(), + TagID: req.TagID, + CreateTagAssignmentInputBody: components.CreateTagAssignmentInputBody{ + AssetID: req.AssetID, + }, + } + res, err = t.censysSDK.client.TagsAndComments.CreateTagAssignment(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagAssignment]{} + return zero, err + } + assignment := res.GetResponseEnvelopeTagAssignment().GetResult() + return Result[components.TagAssignment]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: assignment, + }, nil +} + +func (t *tagsSDK) BulkCreateTagAssignments( + ctx context.Context, + req BulkCreateTagAssignmentsRequest, +) (Result[components.TagOperation], ClientError) { + start := time.Now() + var res *operations.V3TagsBulkCreateAssignmentsResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsBulkCreateAssignmentsRequest{ + OrganizationID: req.OrgID.ToPointer(), + TagID: req.TagID, + BulkCreateTagAssignmentsInputBody: components.BulkCreateTagAssignmentsInputBody{ + Query: req.Query, + MaxAssets: req.MaxAssets.ToPointer(), + }, + } + res, err = t.censysSDK.client.TagsAndComments.BulkCreateTagAssignments(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagOperation]{} + return zero, err + } + operation := res.GetResponseEnvelopeTagOperation().GetResult() + return Result[components.TagOperation]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: operation, + }, nil +} + +func (t *tagsSDK) BulkDeleteTagAssignments( + ctx context.Context, + req BulkDeleteTagAssignmentsRequest, +) (Result[components.TagOperation], ClientError) { + start := time.Now() + var res *operations.V3TagsBulkDeleteAssignmentsResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsBulkDeleteAssignmentsRequest{ + OrganizationID: req.OrgID.ToPointer(), + TagID: req.TagID, + // Both timestamps are optional; an empty body means every assignment. + BulkDeleteTagAssignmentsInputBody: components.BulkDeleteTagAssignmentsInputBody{ + CreatedBefore: req.CreatedBefore.ToPointer(), + CreatedAfter: req.CreatedAfter.ToPointer(), + }, + } + res, err = t.censysSDK.client.TagsAndComments.BulkDeleteTagAssignments(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagOperation]{} + return zero, err + } + operation := res.GetResponseEnvelopeTagOperation().GetResult() + return Result[components.TagOperation]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: operation, + }, nil +} + +func (t *tagsSDK) ListTagAssignments( + ctx context.Context, + req ListTagAssignmentsRequest, +) (Result[components.TagAssignmentsList], ClientError) { + start := time.Now() + var res *operations.V3TagsListAssignmentsResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + sdkReq := operations.V3TagsListAssignmentsRequest{ + OrganizationID: req.OrgID.ToPointer(), + TagID: req.TagID, + AssetID: req.AssetID.ToPointer(), + CreatedBy: req.CreatedBy.ToPointer(), + CreatedBefore: req.CreatedBefore.ToPointer(), + CreatedAfter: req.CreatedAfter.ToPointer(), + PageToken: req.PageToken.ToPointer(), + } + if req.PageSize.IsPresent() { + ps := int(req.PageSize.MustGet()) + sdkReq.PageSize = &ps + } + if req.AssetType.IsPresent() { + at := operations.AssetType(req.AssetType.MustGet()) + sdkReq.AssetType = &at + } + if req.OrderBy.IsPresent() { + ob := operations.V3TagsListAssignmentsQueryParamOrderBy(req.OrderBy.MustGet()) + sdkReq.OrderBy = &ob + } + res, err = t.censysSDK.client.TagsAndComments.ListTagAssignments(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.TagAssignmentsList]{} + return zero, err + } + assignments := res.GetResponseEnvelopeTagAssignmentsList().GetResult() + return Result[components.TagAssignmentsList]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: assignments, + }, nil +} + +func (t *tagsSDK) DeleteTagAssignment( + ctx context.Context, + orgID mo.Option[string], + tagID, assignmentID string, +) (Metadata, ClientError) { + start := time.Now() + var res *operations.V3TagsDeleteAssignmentResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + req := operations.V3TagsDeleteAssignmentRequest{ + OrganizationID: orgID.ToPointer(), + TagID: tagID, + AssignmentID: assignmentID, + } + res, err = t.censysSDK.client.TagsAndComments.DeleteTagAssignment(ctx, req) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + return Metadata{}, err + } + // The delete endpoint returns no body, only response metadata. + return buildResponseMetadata(res, latency, attempts), nil +} + +func (t *tagsSDK) UpdateTag( + ctx context.Context, + req UpdateTagRequest, +) (Result[components.Tag], ClientError) { + start := time.Now() + var res *operations.V3TagsUpdateTagResponse + err, attempts := t.executeWithRetry(ctx, func() ClientError { + var err error + body := components.UpdateTagInputBody{ + Name: req.Name.ToPointer(), + Description: req.Description.ToPointer(), + } + if req.Privacy.IsPresent() { + p := components.UpdateTagInputBodyPrivacy(req.Privacy.MustGet()) + body.Privacy = &p + } + sdkReq := operations.V3TagsUpdateTagRequest{ + OrganizationID: req.OrgID.ToPointer(), + TagID: req.TagID, + UpdateTagInputBody: body, + } + res, err = t.censysSDK.client.TagsAndComments.UpdateTag(ctx, sdkReq) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.Tag]{} + return zero, err + } + tag := res.GetResponseEnvelopeTag().GetResult() + return Result[components.Tag]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: tag, + }, nil +} diff --git a/internal/pkg/domain/identifiers/tag.go b/internal/pkg/domain/identifiers/tag.go new file mode 100644 index 0000000..1299210 --- /dev/null +++ b/internal/pkg/domain/identifiers/tag.go @@ -0,0 +1,33 @@ +package identifiers + +import ( + "strings" + + "github.com/google/uuid" + "github.com/samber/mo" +) + +// TagID identifies a tag by either its name or its UUID. Only the GetTag endpoint +// accepts a name in the tag_id path parameter; the mutate endpoints (update, +// delete) require a UUID, so the tags service resolves a name to its UUID via an +// exact-match ListTags lookup before calling them. UID reports the UUID when the +// raw value parses as one — resolveTagID uses it to skip the lookup for a UUID. +type TagID struct{ raw string } + +// NewTagID builds a TagID from a raw name-or-UUID string, trimming surrounding +// whitespace. +func NewTagID(raw string) TagID { + return TagID{raw: strings.TrimSpace(raw)} +} + +// String returns the raw name-or-UUID value, suitable for the API path parameter. +func (t TagID) String() string { return t.raw } + +// UID returns the parsed UUID when the raw value is a valid UUID, otherwise None +// (i.e. the value is a tag name). +func (t TagID) UID() mo.Option[uuid.UUID] { + if u, err := uuid.Parse(t.raw); err == nil { + return mo.Some(u) + } + return mo.None[uuid.UUID]() +} diff --git a/internal/pkg/domain/identifiers/tag_test.go b/internal/pkg/domain/identifiers/tag_test.go new file mode 100644 index 0000000..9d2e40d --- /dev/null +++ b/internal/pkg/domain/identifiers/tag_test.go @@ -0,0 +1,35 @@ +package identifiers + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestTagID(t *testing.T) { + t.Run("name has no UID", func(t *testing.T) { + id := NewTagID("my-tag") + require.Equal(t, "my-tag", id.String()) + require.True(t, id.UID().IsAbsent()) + }) + + t.Run("uuid parses into UID", func(t *testing.T) { + u := uuid.New() + id := NewTagID(u.String()) + require.Equal(t, u.String(), id.String()) + require.True(t, id.UID().IsPresent()) + require.Equal(t, u, id.UID().MustGet()) + }) + + t.Run("trims surrounding whitespace", func(t *testing.T) { + id := NewTagID(" spaced-tag ") + require.Equal(t, "spaced-tag", id.String()) + require.True(t, id.UID().IsAbsent()) + }) + + t.Run("uppercase uuid still parses", func(t *testing.T) { + id := NewTagID("6BA7B810-9DAD-11D1-80B4-00C04FD430C8") + require.True(t, id.UID().IsPresent()) + }) +} diff --git a/internal/pkg/ui/form/confirm.go b/internal/pkg/ui/form/confirm.go new file mode 100644 index 0000000..47a8953 --- /dev/null +++ b/internal/pkg/ui/form/confirm.go @@ -0,0 +1,37 @@ +package form + +import ( + "context" + "os" + + "github.com/charmbracelet/huh" +) + +// Confirm prompts the user with a yes/no question and returns their answer. +// It reuses the shared form wrapper, so it inherits the default theme, terminal +// restoration, and Ctrl-C handling (returning ErrUserAborted on interrupt). +// +// The caller is responsible for gating this on an interactive terminal; in a +// non-TTY context the underlying form has nothing to read and the caller should +// avoid prompting at all. +func Confirm(ctx context.Context, message string) (bool, error) { + var confirmed bool + f := NewForm( + huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(message). + Affirmative("Yes"). + Negative("No"). + Value(&confirmed), + ), + ). + // Pin the prompt to stderr: on stdout it would land inside the data a + // command redirects, and huh writes to stdout in accessible mode. + WithOutput(os.Stderr), + ) + if err := f.RunWithContext(ctx); err != nil { + return false, err + } + return confirmed, nil +}