diff --git a/cmd/cencli/e2e/fixtures/credits.go b/cmd/cencli/e2e/fixtures/credits.go new file mode 100644 index 0000000..5c7311c --- /dev/null +++ b/cmd/cencli/e2e/fixtures/credits.go @@ -0,0 +1,37 @@ +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/credits" +) + +var creditsFixtures = []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.CreditsHelpStdout, stdout, 0) + }, + }, + { + Name: "basic", + Args: []string{"--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[credits.UserCreditDetails](t, stdout) + assert.Greater(t, data.Balance, int64(0)) + assert.NotNil(t, data.ResetsAt) + }, + }, +} diff --git a/cmd/cencli/e2e/fixtures/fixtures.go b/cmd/cencli/e2e/fixtures/fixtures.go index 524b499..87fe79d 100644 --- a/cmd/cencli/e2e/fixtures/fixtures.go +++ b/cmd/cencli/e2e/fixtures/fixtures.go @@ -29,5 +29,7 @@ func Fixtures() map[string][]Fixture { "search": searchFixtures, "censeye": censeyeFixtures, "history": historyFixtures, + "credits": creditsFixtures, + "org": orgFixtures, } } diff --git a/cmd/cencli/e2e/fixtures/golden/credits_help.out b/cmd/cencli/e2e/fixtures/golden/credits_help.out new file mode 100644 index 0000000..263599b --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/credits_help.out @@ -0,0 +1,23 @@ +Display credit details for your Free user Censys account. + +Note: This command only shows free user credits. If you want to see organization credits, +run "censys org credits" instead. + +Usage: + censys credits [flags] + +Examples: + censys credits # Show free user credits + +Flags: + -h, --help help for credits + +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/golden.go b/cmd/cencli/e2e/fixtures/golden/golden.go index 2a1a9fb..95e348b 100644 --- a/cmd/cencli/e2e/fixtures/golden/golden.go +++ b/cmd/cencli/e2e/fixtures/golden/golden.go @@ -17,4 +17,14 @@ var ( HistoryHelpStdout []byte //go:embed root.out RootStdout []byte + //go:embed credits_help.out + CreditsHelpStdout []byte + //go:embed org_details_help.out + OrgDetailsHelpStdout []byte + //go:embed org_members_help.out + OrgMembersHelpStdout []byte + //go:embed org_credits_help.out + OrgCreditsHelpStdout []byte + //go:embed org_help.out + OrgHelpStdout []byte ) diff --git a/cmd/cencli/e2e/fixtures/golden/org_credits_help.out b/cmd/cencli/e2e/fixtures/golden/org_credits_help.out new file mode 100644 index 0000000..0bf2385 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_credits_help.out @@ -0,0 +1,28 @@ +Display credit details for your organization. + +This command shows your organization's credit balance, auto-replenish configuration, +and any credit expirations. + +By default, the stored organization ID is used. Use --org-id to query a specific +organization. + +Usage: + censys org credits [flags] + +Examples: + censys org credits # Show credits for your stored organization + censys org credits --org-id # Show credits for a specific organization + +Flags: + -h, --help help for credits + -o, --org-id string override the configured organization ID + +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/org_details_help.out b/cmd/cencli/e2e/fixtures/golden/org_details_help.out new file mode 100644 index 0000000..a10f7fc --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_details_help.out @@ -0,0 +1,29 @@ +Display details about your organization. + +This command shows organization information including name, ID, creation date, +and member counts. + +By default, the stored organization ID is used. Use --org-id to query a specific +organization. + +Usage: + censys org details [flags] + +Examples: + censys org details # Show details for your stored organization + censys org details --org-id # Show details for a specific organization + censys org details --output-format json # Output as JSON + +Flags: + -h, --help help for details + -o, --org-id string override the configured organization ID + +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/org_help.out b/cmd/cencli/e2e/fixtures/golden/org_help.out new file mode 100644 index 0000000..7516bb9 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_help.out @@ -0,0 +1,30 @@ +Manage and view organization details including credits, members, and organization +information. + +By default, these commands use your stored organization ID. If no organization ID is +stored, +or you want to query a different organization, use the --org-id flag on each subcommand. + +To set your default organization ID, run: censys config org-id set + +Usage: + censys org [flags] + censys org [command] + +Available Commands: + credits Display credit details for your organization + details Display organization details + members List organization members + +Flags: + -h, --help help for org + +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/org_members_help.out b/cmd/cencli/e2e/fixtures/golden/org_members_help.out new file mode 100644 index 0000000..ca11603 --- /dev/null +++ b/cmd/cencli/e2e/fixtures/golden/org_members_help.out @@ -0,0 +1,31 @@ +List members in your organization. + +This command displays all members including their email, name, roles, and last login time. + +By default, the stored organization ID is used. Use --org-id to query a specific +organization. +Use --interactive for a navigable table view. + +Usage: + censys org members [flags] + +Examples: + censys org members # List members for your stored organization + censys org members --interactive # List members in an interactive table + censys org members --org-id # List members for a specific organization + censys org members --output-format json # Output as JSON + +Flags: + -h, --help help for members + -i, --interactive display results in an interactive table (TUI) + -o, --org-id string override the configured organization ID + +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/root.out b/cmd/cencli/e2e/fixtures/golden/root.out index f5cfbf9..dc19f3a 100644 --- a/cmd/cencli/e2e/fixtures/golden/root.out +++ b/cmd/cencli/e2e/fixtures/golden/root.out @@ -11,7 +11,9 @@ Available Commands: censeye Analyze a host and generate pivotable queries with rarity bounds completion Generate shell completion scripts config Manage configuration + credits Display credit details for your Censys account history Retrieve historical data for hosts, web properties, and certificates + org Manage and view organization details search Execute a search query across Censys data version Print version information view Retrieve information about hosts, certificates, and web properties diff --git a/cmd/cencli/e2e/fixtures/golden/update.sh b/cmd/cencli/e2e/fixtures/golden/update.sh index 7f59723..402fc84 100755 --- a/cmd/cencli/e2e/fixtures/golden/update.sh +++ b/cmd/cencli/e2e/fixtures/golden/update.sh @@ -20,6 +20,11 @@ echo "Updating golden fixtures..." "$BINARY" search --help > search_help.out "$BINARY" censeye --help > censeye_help.out "$BINARY" history --help > history_help.out +"$BINARY" credits --help > credits_help.out +"$BINARY" org details --help > org_details_help.out +"$BINARY" org members --help > org_members_help.out +"$BINARY" org credits --help > org_credits_help.out +"$BINARY" org --help > org_help.out "$BINARY" > root.out echo "✅ All golden fixtures updated" diff --git a/cmd/cencli/e2e/fixtures/org.go b/cmd/cencli/e2e/fixtures/org.go new file mode 100644 index 0000000..d63e8ff --- /dev/null +++ b/cmd/cencli/e2e/fixtures/org.go @@ -0,0 +1,118 @@ +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/credits" + "github.com/censys/cencli/internal/app/organizations" +) + +var orgFixtures = []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.OrgHelpStdout, 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.OrgHelpStdout, stdout, 0) + }, + }, + // ========== credits subcommand ========== + { + Name: "credits help", + Args: []string{"credits", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgCreditsHelpStdout, stdout, 0) + }, + }, + { + Name: "credits basic", + Args: []string{"credits", "--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[credits.OrganizationCreditDetails](t, stdout) + assert.Greater(t, data.Balance, int64(0)) + assert.NotNil(t, data.AutoReplenishConfig) + assert.NotNil(t, data.CreditExpirations) + assert.Greater(t, len(data.CreditExpirations), 0) + for _, creditExpiration := range data.CreditExpirations { + assert.Greater(t, creditExpiration.Balance, int64(0)) + assert.NotNil(t, creditExpiration.CreationDate) + assert.NotNil(t, creditExpiration.ExpirationDate) + } + }, + }, + // ========== members subcommand ========== + { + Name: "members help", + Args: []string{"members", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgMembersHelpStdout, stdout, 0) + }, + }, + { + Name: "members basic", + Args: []string{"members", "--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[organizations.OrganizationMembers](t, stdout) + assert.Greater(t, len(data.Members), 0) + for _, member := range data.Members { + assert.NotEmpty(t, member.Email) + assert.NotEmpty(t, member.Roles) + } + }, + }, + // ========== details subcommand ========== + { + Name: "details help", + Args: []string{"details", "--help"}, + ExitCode: 0, + Timeout: 1 * time.Second, + NeedsAuth: false, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertGoldenFile(t, golden.OrgDetailsHelpStdout, stdout, 0) + }, + }, + { + Name: "details basic", + Args: []string{"details", "--output-format", "json"}, + ExitCode: 0, + Timeout: 5 * time.Second, + NeedsAuth: true, + Assert: func(t *testing.T, stdout, stderr []byte) { + assertHas200(t, stderr) + data := unmarshalJSONAny[organizations.OrganizationDetails](t, stdout) + assert.NotEmpty(t, data.Name) + assert.NotEmpty(t, data.CreatedAt) + assert.NotEmpty(t, data.MemberCounts) + }, + }, +} diff --git a/gen/app/credits/mocks/creditservice_mock.go b/gen/app/credits/mocks/creditservice_mock.go new file mode 100644 index 0000000..038e640 --- /dev/null +++ b/gen/app/credits/mocks/creditservice_mock.go @@ -0,0 +1,74 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/app/credits (interfaces: Service) +// +// Generated by this command: +// +// mockgen -destination=../../../gen/app/credits/mocks/creditservice_mock.go -package=mocks -mock_names Service=MockCreditsService . Service +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + credits "github.com/censys/cencli/internal/app/credits" + cenclierrors "github.com/censys/cencli/internal/pkg/cenclierrors" + identifiers "github.com/censys/cencli/internal/pkg/domain/identifiers" + gomock "go.uber.org/mock/gomock" +) + +// MockCreditsService is a mock of Service interface. +type MockCreditsService struct { + ctrl *gomock.Controller + recorder *MockCreditsServiceMockRecorder + isgomock struct{} +} + +// MockCreditsServiceMockRecorder is the mock recorder for MockCreditsService. +type MockCreditsServiceMockRecorder struct { + mock *MockCreditsService +} + +// NewMockCreditsService creates a new mock instance. +func NewMockCreditsService(ctrl *gomock.Controller) *MockCreditsService { + mock := &MockCreditsService{ctrl: ctrl} + mock.recorder = &MockCreditsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCreditsService) EXPECT() *MockCreditsServiceMockRecorder { + return m.recorder +} + +// GetOrganizationCreditDetails mocks base method. +func (m *MockCreditsService) GetOrganizationCreditDetails(ctx context.Context, orgID identifiers.OrganizationID) (credits.OrganizationCreditDetailsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationCreditDetails", ctx, orgID) + ret0, _ := ret[0].(credits.OrganizationCreditDetailsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetOrganizationCreditDetails indicates an expected call of GetOrganizationCreditDetails. +func (mr *MockCreditsServiceMockRecorder) GetOrganizationCreditDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationCreditDetails", reflect.TypeOf((*MockCreditsService)(nil).GetOrganizationCreditDetails), ctx, orgID) +} + +// GetUserCreditDetails mocks base method. +func (m *MockCreditsService) GetUserCreditDetails(ctx context.Context) (credits.UserCreditDetailsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCreditDetails", ctx) + ret0, _ := ret[0].(credits.UserCreditDetailsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetUserCreditDetails indicates an expected call of GetUserCreditDetails. +func (mr *MockCreditsServiceMockRecorder) GetUserCreditDetails(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCreditDetails", reflect.TypeOf((*MockCreditsService)(nil).GetUserCreditDetails), ctx) +} diff --git a/gen/app/organizations/mocks/organizationservice_mock.go b/gen/app/organizations/mocks/organizationservice_mock.go new file mode 100644 index 0000000..11415e7 --- /dev/null +++ b/gen/app/organizations/mocks/organizationservice_mock.go @@ -0,0 +1,75 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/app/organizations (interfaces: Service) +// +// Generated by this command: +// +// mockgen -destination=../../../gen/app/organizations/mocks/organizationservice_mock.go -package=mocks -mock_names Service=MockOrganizationsService . Service +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + organizations "github.com/censys/cencli/internal/app/organizations" + cenclierrors "github.com/censys/cencli/internal/pkg/cenclierrors" + identifiers "github.com/censys/cencli/internal/pkg/domain/identifiers" + mo "github.com/samber/mo" + gomock "go.uber.org/mock/gomock" +) + +// MockOrganizationsService is a mock of Service interface. +type MockOrganizationsService struct { + ctrl *gomock.Controller + recorder *MockOrganizationsServiceMockRecorder + isgomock struct{} +} + +// MockOrganizationsServiceMockRecorder is the mock recorder for MockOrganizationsService. +type MockOrganizationsServiceMockRecorder struct { + mock *MockOrganizationsService +} + +// NewMockOrganizationsService creates a new mock instance. +func NewMockOrganizationsService(ctrl *gomock.Controller) *MockOrganizationsService { + mock := &MockOrganizationsService{ctrl: ctrl} + mock.recorder = &MockOrganizationsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOrganizationsService) EXPECT() *MockOrganizationsServiceMockRecorder { + return m.recorder +} + +// GetOrganizationDetails mocks base method. +func (m *MockOrganizationsService) GetOrganizationDetails(ctx context.Context, orgID identifiers.OrganizationID) (organizations.OrganizationDetailsResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationDetails", ctx, orgID) + ret0, _ := ret[0].(organizations.OrganizationDetailsResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// GetOrganizationDetails indicates an expected call of GetOrganizationDetails. +func (mr *MockOrganizationsServiceMockRecorder) GetOrganizationDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockOrganizationsService)(nil).GetOrganizationDetails), ctx, orgID) +} + +// ListOrganizationMembers mocks base method. +func (m *MockOrganizationsService) ListOrganizationMembers(ctx context.Context, orgID identifiers.OrganizationID, pageSize, maxPages mo.Option[uint]) (organizations.OrganizationMembersResult, cenclierrors.CencliError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOrganizationMembers", ctx, orgID, pageSize, maxPages) + ret0, _ := ret[0].(organizations.OrganizationMembersResult) + ret1, _ := ret[1].(cenclierrors.CencliError) + return ret0, ret1 +} + +// ListOrganizationMembers indicates an expected call of ListOrganizationMembers. +func (mr *MockOrganizationsServiceMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, maxPages any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockOrganizationsService)(nil).ListOrganizationMembers), ctx, orgID, pageSize, maxPages) +} diff --git a/gen/client/mocks/accountmanagement_mock.go b/gen/client/mocks/accountmanagement_mock.go new file mode 100644 index 0000000..0bfcee1 --- /dev/null +++ b/gen/client/mocks/accountmanagement_mock.go @@ -0,0 +1,104 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/censys/cencli/internal/pkg/clients/censys (interfaces: AccountManagementClient) +// +// Generated by this command: +// +// mockgen -destination=../../../../gen/client/mocks/accountmanagement_mock.go -package=mocks github.com/censys/cencli/internal/pkg/clients/censys AccountManagementClient +// + +// 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" +) + +// MockAccountManagementClient is a mock of AccountManagementClient interface. +type MockAccountManagementClient struct { + ctrl *gomock.Controller + recorder *MockAccountManagementClientMockRecorder + isgomock struct{} +} + +// MockAccountManagementClientMockRecorder is the mock recorder for MockAccountManagementClient. +type MockAccountManagementClientMockRecorder struct { + mock *MockAccountManagementClient +} + +// NewMockAccountManagementClient creates a new mock instance. +func NewMockAccountManagementClient(ctrl *gomock.Controller) *MockAccountManagementClient { + mock := &MockAccountManagementClient{ctrl: ctrl} + mock.recorder = &MockAccountManagementClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountManagementClient) EXPECT() *MockAccountManagementClientMockRecorder { + return m.recorder +} + +// GetOrganizationCreditDetails mocks base method. +func (m *MockAccountManagementClient) GetOrganizationCreditDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationCreditDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationCreditDetails indicates an expected call of GetOrganizationCreditDetails. +func (mr *MockAccountManagementClientMockRecorder) GetOrganizationCreditDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationCreditDetails", reflect.TypeOf((*MockAccountManagementClient)(nil).GetOrganizationCreditDetails), ctx, orgID) +} + +// GetOrganizationDetails mocks base method. +func (m *MockAccountManagementClient) GetOrganizationDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationDetails], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationDetails]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationDetails indicates an expected call of GetOrganizationDetails. +func (mr *MockAccountManagementClientMockRecorder) GetOrganizationDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockAccountManagementClient)(nil).GetOrganizationDetails), ctx, orgID) +} + +// GetUserCreditDetails mocks base method. +func (m *MockAccountManagementClient) GetUserCreditDetails(ctx context.Context) (censys.Result[components.UserCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCreditDetails", ctx) + ret0, _ := ret[0].(censys.Result[components.UserCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetUserCreditDetails indicates an expected call of GetUserCreditDetails. +func (mr *MockAccountManagementClientMockRecorder) GetUserCreditDetails(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCreditDetails", reflect.TypeOf((*MockAccountManagementClient)(nil).GetUserCreditDetails), ctx) +} + +// ListOrganizationMembers mocks base method. +func (m *MockAccountManagementClient) ListOrganizationMembers(ctx context.Context, orgID string, pageSize mo.Option[int], pageToken mo.Option[string]) (censys.Result[components.OrganizationMembersList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOrganizationMembers", ctx, orgID, pageSize, pageToken) + ret0, _ := ret[0].(censys.Result[components.OrganizationMembersList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListOrganizationMembers indicates an expected call of ListOrganizationMembers. +func (mr *MockAccountManagementClientMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, pageToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockAccountManagementClient)(nil).ListOrganizationMembers), ctx, orgID, pageSize, pageToken) +} diff --git a/gen/client/mocks/censys_client_mock.go b/gen/client/mocks/censys_client_mock.go index cd2b4c3..9b5a042 100644 --- a/gen/client/mocks/censys_client_mock.go +++ b/gen/client/mocks/censys_client_mock.go @@ -119,6 +119,51 @@ func (mr *MockClientMockRecorder) GetHosts(ctx, orgID, hostIDs, atTime any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHosts", reflect.TypeOf((*MockClient)(nil).GetHosts), ctx, orgID, hostIDs, atTime) } +// GetOrganizationCreditDetails mocks base method. +func (m *MockClient) GetOrganizationCreditDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationCreditDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationCreditDetails indicates an expected call of GetOrganizationCreditDetails. +func (mr *MockClientMockRecorder) GetOrganizationCreditDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationCreditDetails", reflect.TypeOf((*MockClient)(nil).GetOrganizationCreditDetails), ctx, orgID) +} + +// GetOrganizationDetails mocks base method. +func (m *MockClient) GetOrganizationDetails(ctx context.Context, orgID string) (censys.Result[components.OrganizationDetails], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationDetails", ctx, orgID) + ret0, _ := ret[0].(censys.Result[components.OrganizationDetails]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetOrganizationDetails indicates an expected call of GetOrganizationDetails. +func (mr *MockClientMockRecorder) GetOrganizationDetails(ctx, orgID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationDetails", reflect.TypeOf((*MockClient)(nil).GetOrganizationDetails), ctx, orgID) +} + +// GetUserCreditDetails mocks base method. +func (m *MockClient) GetUserCreditDetails(ctx context.Context) (censys.Result[components.UserCredits], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCreditDetails", ctx) + ret0, _ := ret[0].(censys.Result[components.UserCredits]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// GetUserCreditDetails indicates an expected call of GetUserCreditDetails. +func (mr *MockClientMockRecorder) GetUserCreditDetails(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCreditDetails", reflect.TypeOf((*MockClient)(nil).GetUserCreditDetails), ctx) +} + // GetValueCounts mocks base method. func (m *MockClient) GetValueCounts(ctx context.Context, orgID, query mo.Option[string], andCountConditions []components.CountCondition) (censys.Result[components.ValueCountsResponse], censys.ClientError) { m.ctrl.T.Helper() @@ -178,6 +223,21 @@ func (mr *MockClientMockRecorder) HostTimeline(ctx, orgID, hostID, fromTime, toT return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HostTimeline", reflect.TypeOf((*MockClient)(nil).HostTimeline), ctx, orgID, hostID, fromTime, toTime) } +// ListOrganizationMembers mocks base method. +func (m *MockClient) ListOrganizationMembers(ctx context.Context, orgID string, pageSize mo.Option[int], pageToken mo.Option[string]) (censys.Result[components.OrganizationMembersList], censys.ClientError) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOrganizationMembers", ctx, orgID, pageSize, pageToken) + ret0, _ := ret[0].(censys.Result[components.OrganizationMembersList]) + ret1, _ := ret[1].(censys.ClientError) + return ret0, ret1 +} + +// ListOrganizationMembers indicates an expected call of ListOrganizationMembers. +func (mr *MockClientMockRecorder) ListOrganizationMembers(ctx, orgID, pageSize, pageToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOrganizationMembers", reflect.TypeOf((*MockClient)(nil).ListOrganizationMembers), ctx, orgID, pageSize, pageToken) +} + // 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() diff --git a/internal/app/credits/dto.go b/internal/app/credits/dto.go new file mode 100644 index 0000000..74456f7 --- /dev/null +++ b/internal/app/credits/dto.go @@ -0,0 +1,86 @@ +package credits + +import ( + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +type OrganizationCreditDetailsResult struct { + Meta *responsemeta.ResponseMeta + Data OrganizationCreditDetails +} + +type OrganizationCreditDetails struct { + Balance int64 `json:"balance"` + CreditExpirations []CreditExpiration `json:"credit_expirations"` + AutoReplenishConfig AutoReplenishConfig `json:"auto_replenish_config"` +} + +type CreditExpiration struct { + Balance int64 `json:"balance"` + CreationDate mo.Option[time.Time] `json:"creation_date,omitzero"` + ExpirationDate mo.Option[time.Time] `json:"expiration_date,omitzero"` +} + +type AutoReplenishConfig struct { + Enabled bool `json:"enabled"` + Threshold mo.Option[int64] `json:"threshold,omitzero"` + Amount mo.Option[int64] `json:"amount,omitzero"` +} + +func parseOrganizationCreditDetails(credits *components.OrganizationCredits) OrganizationCreditDetails { + autoReplenishConfig := AutoReplenishConfig{ + Enabled: credits.AutoReplenishConfig.Enabled, + } + if credits.GetAutoReplenishConfig().Threshold != nil { + autoReplenishConfig.Threshold = mo.Some(*credits.GetAutoReplenishConfig().Threshold) + } + if credits.GetAutoReplenishConfig().Amount != nil { + autoReplenishConfig.Amount = mo.Some(*credits.GetAutoReplenishConfig().Amount) + } + var creditExpirations []CreditExpiration + if len(credits.GetCreditExpirations()) > 0 { + creditExpirations = make([]CreditExpiration, 0, len(credits.GetCreditExpirations())) + for _, creditExpiration := range credits.GetCreditExpirations() { + ce := CreditExpiration{ + Balance: creditExpiration.Balance, + } + if creditExpiration.CreatedAt != nil { + ce.CreationDate = mo.Some(*creditExpiration.CreatedAt) + } + if creditExpiration.ExpiresAt != nil { + ce.ExpirationDate = mo.Some(*creditExpiration.ExpiresAt) + } + creditExpirations = append(creditExpirations, ce) + } + } + return OrganizationCreditDetails{ + Balance: credits.Balance, + CreditExpirations: creditExpirations, + AutoReplenishConfig: autoReplenishConfig, + } +} + +type UserCreditDetailsResult struct { + Meta *responsemeta.ResponseMeta + Data UserCreditDetails +} + +type UserCreditDetails struct { + Balance int64 `json:"balance"` + ResetsAt mo.Option[time.Time] `json:"resets_at,omitzero"` +} + +func parseUserCreditDetails(credits *components.UserCredits) UserCreditDetails { + ucd := UserCreditDetails{ + Balance: credits.Balance, + } + if credits.ResetsAt != nil { + ucd.ResetsAt = mo.Some(*credits.ResetsAt) + } + return ucd +} diff --git a/internal/app/credits/service.go b/internal/app/credits/service.go new file mode 100644 index 0000000..1e9dd02 --- /dev/null +++ b/internal/app/credits/service.go @@ -0,0 +1,58 @@ +package credits + +import ( + "context" + + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +//go:generate mockgen -destination=../../../gen/app/credits/mocks/creditservice_mock.go -package=mocks -mock_names Service=MockCreditsService . Service + +// Service provides credit details capabilities. +type Service interface { + // GetOrganizationCreditDetails retrieves the credit details for an organization. + GetOrganizationCreditDetails( + ctx context.Context, + orgID identifiers.OrganizationID, + ) (OrganizationCreditDetailsResult, cenclierrors.CencliError) + // GetUserCreditDetails retrieves the credit details for the current user. + GetUserCreditDetails( + ctx context.Context, + ) (UserCreditDetailsResult, cenclierrors.CencliError) +} + +type creditsService struct { + client client.Client +} + +func New(client client.Client) Service { + return &creditsService{client: client} +} + +func (s *creditsService) GetOrganizationCreditDetails( + ctx context.Context, + orgID identifiers.OrganizationID, +) (OrganizationCreditDetailsResult, cenclierrors.CencliError) { + res, err := s.client.GetOrganizationCreditDetails(ctx, orgID.String()) + if err != nil { + return OrganizationCreditDetailsResult{}, err + } + return OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts), + Data: parseOrganizationCreditDetails(res.Data), + }, nil +} + +func (s *creditsService) GetUserCreditDetails(ctx context.Context) (UserCreditDetailsResult, cenclierrors.CencliError) { + res, err := s.client.GetUserCreditDetails(ctx) + if err != nil { + return UserCreditDetailsResult{}, err + } + return UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts), + Data: parseUserCreditDetails(res.Data), + }, nil +} diff --git a/internal/app/credits/service_test.go b/internal/app/credits/service_test.go new file mode 100644 index 0000000..887fc0d --- /dev/null +++ b/internal/app/credits/service_test.go @@ -0,0 +1,695 @@ +package credits + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/sdkerrors" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "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" +) + +func TestCreditsService_GetOrganizationCreditDetails(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + orgID uuid.UUID + ctx func() context.Context + assert func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) + }{ + { + name: "success - basic organization credits", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationCredits{ + Balance: 1000, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 100*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(1000), res.Data.Balance) + require.Len(t, res.Data.CreditExpirations, 0) + require.False(t, res.Data.AutoReplenishConfig.Enabled) + require.False(t, res.Data.AutoReplenishConfig.Threshold.IsPresent()) + require.False(t, res.Data.AutoReplenishConfig.Amount.IsPresent()) + }, + }, + { + name: "success - with credit expirations", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + expiresAt := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 150 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationCredits{ + Balance: 5000, + CreditExpirations: []components.CreditExpiration{ + { + Balance: 2000, + CreatedAt: &createdAt, + ExpiresAt: &expiresAt, + }, + { + Balance: 3000, + CreatedAt: &createdAt, + ExpiresAt: &expiresAt, + }, + }, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 150*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(5000), res.Data.Balance) + require.Len(t, res.Data.CreditExpirations, 2) + require.Equal(t, int64(2000), res.Data.CreditExpirations[0].Balance) + require.True(t, res.Data.CreditExpirations[0].CreationDate.IsPresent()) + require.True(t, res.Data.CreditExpirations[0].ExpirationDate.IsPresent()) + }, + }, + { + name: "success - with auto replenish config enabled", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + threshold := int64(100) + amount := int64(500) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationCredits{ + Balance: 2500, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: true, + Threshold: &threshold, + Amount: &amount, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, int64(2500), res.Data.Balance) + require.True(t, res.Data.AutoReplenishConfig.Enabled) + require.True(t, res.Data.AutoReplenishConfig.Threshold.IsPresent()) + require.Equal(t, int64(100), res.Data.AutoReplenishConfig.Threshold.MustGet()) + require.True(t, res.Data.AutoReplenishConfig.Amount.IsPresent()) + require.Equal(t, int64(500), res.Data.AutoReplenishConfig.Amount.MustGet()) + }, + }, + { + name: "error - structured client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Organization not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(404), structuredErr.StatusCode().MustGet()) + }, + }, + { + name: "error - generic client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Internal server error", + StatusCode: 500, + Body: "Server temporarily unavailable", + }) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{}, genericErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(500), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - unknown client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + unknownErr := client.NewClientError(errors.New("network timeout")) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationCredits]{}, unknownErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + + var unknownErr client.ClientError + require.True(t, errors.As(err, &unknownErr)) + require.Contains(t, err.Error(), "network timeout") + }, + }, + { + name: "context cancellation - cancelled context", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).DoAndReturn(func(ctx context.Context, orgID string) (client.Result[components.OrganizationCredits], client.ClientError) { + select { + case <-ctx.Done(): + return client.Result[components.OrganizationCredits]{}, client.NewClientError(ctx.Err()) + default: + t.Error("Expected context to be cancelled") + return client.Result[components.OrganizationCredits]{}, client.NewClientError(errors.New("context should have been cancelled")) + } + }) + return mockClient + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + assert: func(t *testing.T, res OrganizationCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationCreditDetailsResult{}, res) + require.Contains(t, err.Error(), "the operation's context was cancelled before it completed") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.GetOrganizationCreditDetails(ctx, identifiers.NewOrganizationID(tc.orgID)) + tc.assert(t, res, err) + }) + } +} + +func TestCreditsService_GetUserCreditDetails(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + ctx func() context.Context + assert func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) + }{ + { + name: "success - basic user credits", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 80 * time.Millisecond, + Attempts: 1, + }, + Data: &components.UserCredits{ + Balance: 500, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 80*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(500), res.Data.Balance) + require.False(t, res.Data.ResetsAt.IsPresent()) + }, + }, + { + name: "success - with resets_at", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + resetsAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 90 * time.Millisecond, + Attempts: 1, + }, + Data: &components.UserCredits{ + Balance: 1000, + ResetsAt: &resetsAt, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 90*time.Millisecond, res.Meta.Latency) + require.Equal(t, int64(1000), res.Data.Balance) + require.True(t, res.Data.ResetsAt.IsPresent()) + require.Equal(t, time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), res.Data.ResetsAt.MustGet()) + }, + }, + { + name: "success - zero balance", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.UserCredits{ + Balance: 0, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, int64(0), res.Data.Balance) + }, + }, + { + name: "error - structured client error (unauthorized)", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Unauthorized access" + status := int64(401) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(401), structuredErr.StatusCode().MustGet()) + }, + }, + { + name: "error - generic client error", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Service unavailable", + StatusCode: 503, + Body: "Try again later", + }) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{}, genericErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(503), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - unknown client error", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + unknownErr := client.NewClientError(errors.New("connection refused")) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(client.Result[components.UserCredits]{}, unknownErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + + var unknownErr client.ClientError + require.True(t, errors.As(err, &unknownErr)) + require.Contains(t, err.Error(), "connection refused") + }, + }, + { + name: "context cancellation - cancelled context", + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).DoAndReturn(func(ctx context.Context) (client.Result[components.UserCredits], client.ClientError) { + select { + case <-ctx.Done(): + return client.Result[components.UserCredits]{}, client.NewClientError(ctx.Err()) + default: + t.Error("Expected context to be cancelled") + return client.Result[components.UserCredits]{}, client.NewClientError(errors.New("context should have been cancelled")) + } + }) + return mockClient + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + assert: func(t *testing.T, res UserCreditDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, UserCreditDetailsResult{}, res) + require.Contains(t, err.Error(), "the operation's context was cancelled before it completed") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.GetUserCreditDetails(ctx) + tc.assert(t, res, err) + }) + } +} + +// TestParseOrganizationCreditDetails tests the organization credit details parsing functionality +func TestParseOrganizationCreditDetails(t *testing.T) { + testCases := []struct { + name string + input *components.OrganizationCredits + expected OrganizationCreditDetails + }{ + { + name: "empty credit expirations and disabled auto replenish", + input: &components.OrganizationCredits{ + Balance: 1000, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + expected: OrganizationCreditDetails{ + Balance: 1000, + CreditExpirations: nil, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: false, + }, + }, + }, + { + name: "with credit expirations and dates", + input: func() *components.OrganizationCredits { + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + expiresAt := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + return &components.OrganizationCredits{ + Balance: 5000, + CreditExpirations: []components.CreditExpiration{ + { + Balance: 2500, + CreatedAt: &createdAt, + ExpiresAt: &expiresAt, + }, + }, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + } + }(), + expected: func() OrganizationCreditDetails { + return OrganizationCreditDetails{ + Balance: 5000, + CreditExpirations: []CreditExpiration{ + { + Balance: 2500, + CreationDate: mo.Some(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + ExpirationDate: mo.Some(time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)), + }, + }, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: false, + }, + } + }(), + }, + { + name: "with auto replenish enabled", + input: func() *components.OrganizationCredits { + threshold := int64(100) + amount := int64(1000) + return &components.OrganizationCredits{ + Balance: 3000, + CreditExpirations: []components.CreditExpiration{}, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: true, + Threshold: &threshold, + Amount: &amount, + }, + } + }(), + expected: func() OrganizationCreditDetails { + return OrganizationCreditDetails{ + Balance: 3000, + CreditExpirations: nil, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: true, + Threshold: mo.Some(int64(100)), + Amount: mo.Some(int64(1000)), + }, + } + }(), + }, + { + name: "credit expiration without dates", + input: &components.OrganizationCredits{ + Balance: 1500, + CreditExpirations: []components.CreditExpiration{ + { + Balance: 1500, + }, + }, + AutoReplenishConfig: components.AutoReplenishConfig{ + Enabled: false, + }, + }, + expected: OrganizationCreditDetails{ + Balance: 1500, + CreditExpirations: []CreditExpiration{ + { + Balance: 1500, + }, + }, + AutoReplenishConfig: AutoReplenishConfig{ + Enabled: false, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := parseOrganizationCreditDetails(tc.input) + require.Equal(t, tc.expected.Balance, result.Balance) + require.Equal(t, tc.expected.AutoReplenishConfig.Enabled, result.AutoReplenishConfig.Enabled) + require.Equal(t, tc.expected.AutoReplenishConfig.Threshold.IsPresent(), result.AutoReplenishConfig.Threshold.IsPresent()) + require.Equal(t, tc.expected.AutoReplenishConfig.Amount.IsPresent(), result.AutoReplenishConfig.Amount.IsPresent()) + if tc.expected.AutoReplenishConfig.Threshold.IsPresent() { + require.Equal(t, tc.expected.AutoReplenishConfig.Threshold.MustGet(), result.AutoReplenishConfig.Threshold.MustGet()) + } + if tc.expected.AutoReplenishConfig.Amount.IsPresent() { + require.Equal(t, tc.expected.AutoReplenishConfig.Amount.MustGet(), result.AutoReplenishConfig.Amount.MustGet()) + } + require.Len(t, result.CreditExpirations, len(tc.expected.CreditExpirations)) + for i, ce := range tc.expected.CreditExpirations { + require.Equal(t, ce.Balance, result.CreditExpirations[i].Balance) + require.Equal(t, ce.CreationDate.IsPresent(), result.CreditExpirations[i].CreationDate.IsPresent()) + require.Equal(t, ce.ExpirationDate.IsPresent(), result.CreditExpirations[i].ExpirationDate.IsPresent()) + } + }) + } +} + +// TestParseUserCreditDetails tests the user credit details parsing functionality +func TestParseUserCreditDetails(t *testing.T) { + testCases := []struct { + name string + input *components.UserCredits + expected UserCreditDetails + }{ + { + name: "basic balance without resets_at", + input: &components.UserCredits{ + Balance: 500, + }, + expected: UserCreditDetails{ + Balance: 500, + }, + }, + { + name: "with resets_at", + input: func() *components.UserCredits { + resetsAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + return &components.UserCredits{ + Balance: 1000, + ResetsAt: &resetsAt, + } + }(), + expected: func() UserCreditDetails { + return UserCreditDetails{ + Balance: 1000, + ResetsAt: mo.Some(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)), + } + }(), + }, + { + name: "zero balance", + input: &components.UserCredits{ + Balance: 0, + }, + expected: UserCreditDetails{ + Balance: 0, + }, + }, + { + name: "large balance", + input: &components.UserCredits{ + Balance: 9223372036854775807, + }, + expected: UserCreditDetails{ + Balance: 9223372036854775807, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := parseUserCreditDetails(tc.input) + require.Equal(t, tc.expected.Balance, result.Balance) + require.Equal(t, tc.expected.ResetsAt.IsPresent(), result.ResetsAt.IsPresent()) + if tc.expected.ResetsAt.IsPresent() { + require.Equal(t, tc.expected.ResetsAt.MustGet(), result.ResetsAt.MustGet()) + } + }) + } +} diff --git a/internal/app/organizations/dto.go b/internal/app/organizations/dto.go new file mode 100644 index 0000000..3d4d33d --- /dev/null +++ b/internal/app/organizations/dto.go @@ -0,0 +1,113 @@ +package organizations + +import ( + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/google/uuid" + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +type OrganizationDetailsResult struct { + Meta *responsemeta.ResponseMeta + Data OrganizationDetails +} + +type OrganizationDetails struct { + ID uuid.UUID `json:"id"` + CreatedAt mo.Option[time.Time] `json:"created_at,omitzero"` + Name string `json:"name"` + MemberCounts *components.MemberCounts `json:"member_counts,omitempty"` + Preferences *components.OrganizationPreferences `json:"preferences,omitempty"` +} + +func parseOrganizationDetails(details *components.OrganizationDetails) OrganizationDetails { + var id uuid.UUID = uuid.Nil + if uid, err := uuid.Parse(details.UID); err == nil { + id = uid + } + var createdAt mo.Option[time.Time] + if details.CreatedAt != nil { + createdAt = mo.Some(*details.CreatedAt) + } + return OrganizationDetails{ + ID: id, + CreatedAt: createdAt, + Name: details.Name, + MemberCounts: details.MemberCounts, + Preferences: details.Preferences, + } +} + +type OrganizationMembersResult struct { + Meta *responsemeta.ResponseMeta + Data OrganizationMembers +} + +type OrganizationMembers struct { + Members []OrganizationMember `json:"members"` +} + +type OrganizationMember struct { + ID uuid.UUID `json:"id"` + CreatedAt mo.Option[time.Time] `json:"created_at,omitzero"` + Email mo.Option[string] `json:"email,omitzero"` + FirstName mo.Option[string] `json:"first_name,omitzero"` + LastName mo.Option[string] `json:"last_name,omitzero"` + Roles []string `json:"roles,omitempty"` + LatestLoginTime mo.Option[time.Time] `json:"latest_login_time,omitzero"` + FirstLoginTime mo.Option[time.Time] `json:"first_login_time,omitzero"` +} + +func parseOrganizationMembers(members *components.OrganizationMembersList) OrganizationMembers { + om := OrganizationMembers{ + Members: make([]OrganizationMember, 0, len(members.Members)), + } + for _, member := range members.Members { + om.Members = append(om.Members, parseOrganizationMember(member)) + } + return om +} + +func parseOrganizationMember(member components.OrganizationMember) OrganizationMember { + var id uuid.UUID = uuid.Nil + if uid, err := uuid.Parse(member.UID); err == nil { + id = uid + } + var createdAt mo.Option[time.Time] + if member.CreatedAt != nil { + createdAt = mo.Some(*member.CreatedAt) + } + var email mo.Option[string] + if member.Email != "" { + email = mo.Some(member.Email) + } + var firstName mo.Option[string] + if member.FirstName != "" { + firstName = mo.Some(member.FirstName) + } + var lastName mo.Option[string] + if member.LastName != "" { + lastName = mo.Some(member.LastName) + } + var latestLoginTime mo.Option[time.Time] + if member.LatestLoginTime != nil { + latestLoginTime = mo.Some(*member.LatestLoginTime) + } + var firstLoginTime mo.Option[time.Time] + if member.FirstLoginTime != nil { + firstLoginTime = mo.Some(*member.FirstLoginTime) + } + return OrganizationMember{ + ID: id, + CreatedAt: createdAt, + Email: email, + FirstName: firstName, + LastName: lastName, + Roles: member.Roles, + LatestLoginTime: latestLoginTime, + FirstLoginTime: firstLoginTime, + } +} diff --git a/internal/app/organizations/service.go b/internal/app/organizations/service.go new file mode 100644 index 0000000..85b05da --- /dev/null +++ b/internal/app/organizations/service.go @@ -0,0 +1,118 @@ +package organizations + +import ( + "context" + + "github.com/samber/mo" + + "github.com/censys/cencli/internal/pkg/cenclierrors" + client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" +) + +//go:generate mockgen -destination=../../../gen/app/organizations/mocks/organizationservice_mock.go -package=mocks -mock_names Service=MockOrganizationsService . Service + +// Service provides organization and member details capabilities. +type Service interface { + // GetOrganizationDetails retrieves the details for an organization. + GetOrganizationDetails( + ctx context.Context, + orgID identifiers.OrganizationID, + ) (OrganizationDetailsResult, cenclierrors.CencliError) + // ListOrganizationMembers retrieves the members for an organization. + // If no pagination is provided, the client will return all members. + ListOrganizationMembers( + ctx context.Context, + orgID identifiers.OrganizationID, + pageSize mo.Option[uint], + maxPages mo.Option[uint], + ) (OrganizationMembersResult, cenclierrors.CencliError) +} + +type organizationsService struct { + client client.Client +} + +func New(client client.Client) Service { + return &organizationsService{client: client} +} + +func (s *organizationsService) GetOrganizationDetails( + ctx context.Context, + orgID identifiers.OrganizationID, +) (OrganizationDetailsResult, cenclierrors.CencliError) { + res, err := s.client.GetOrganizationDetails(ctx, orgID.String()) + if err != nil { + return OrganizationDetailsResult{}, err + } + return OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(res.Metadata.Request, res.Metadata.Response, res.Metadata.Latency, res.Metadata.Attempts), + Data: parseOrganizationDetails(res.Data), + }, nil +} + +func (s *organizationsService) ListOrganizationMembers( + ctx context.Context, + orgID identifiers.OrganizationID, + pageSize mo.Option[uint], + maxPages mo.Option[uint], +) (OrganizationMembersResult, cenclierrors.CencliError) { + var allMembers []OrganizationMember + var lastMeta *responsemeta.ResponseMeta + var pagesProcessed uint + pageToken := mo.None[string]() + + // Convert pageSize from uint to int for the client + var clientPageSize mo.Option[int] + if pageSize.IsPresent() { + clientPageSize = mo.Some(int(pageSize.MustGet())) + } + + for { + // Check if we've reached maxPages + if maxPages.IsPresent() && pagesProcessed >= maxPages.MustGet() { + break + } + + // Fetch a page of members + res, err := s.client.ListOrganizationMembers(ctx, orgID.String(), clientPageSize, pageToken) + if err != nil { + // Return error immediately - no partial results for this endpoint + return OrganizationMembersResult{}, err + } + + // Store metadata from the last successful request + if res.Metadata.Request != nil || res.Metadata.Response != nil { + lastMeta = responsemeta.NewResponseMeta( + res.Metadata.Request, + res.Metadata.Response, + res.Metadata.Latency, + res.Metadata.Attempts, + ) + } + + // Parse and append members from this page + if res.Data != nil { + pageMembers := parseOrganizationMembers(res.Data) + allMembers = append(allMembers, pageMembers.Members...) + } + + pagesProcessed++ + + // Check if there's a next page + if res.Data == nil || res.Data.Pagination.GetNextPageToken() == nil || *res.Data.Pagination.GetNextPageToken() == "" { + break + } + + // Set the next page token + pageToken = mo.Some(*res.Data.Pagination.GetNextPageToken()) + } + + return OrganizationMembersResult{ + Meta: lastMeta, + Data: OrganizationMembers{ + Members: allMembers, + }, + }, nil +} diff --git a/internal/app/organizations/service_test.go b/internal/app/organizations/service_test.go new file mode 100644 index 0000000..c2a8340 --- /dev/null +++ b/internal/app/organizations/service_test.go @@ -0,0 +1,624 @@ +package organizations + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/sdkerrors" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "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" +) + +func TestOrganizationsService_GetOrganizationDetails(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + orgID uuid.UUID + ctx func() context.Context + assert func(t *testing.T, res OrganizationDetailsResult, err cenclierrors.CencliError) + }{ + { + name: "success - basic organization details", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + mockClient.EXPECT().GetOrganizationDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationDetails]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationDetails{ + UID: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + CreatedAt: &createdAt, + Name: "Test Organization", + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationDetailsResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 100*time.Millisecond, res.Meta.Latency) + require.Equal(t, uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), res.Data.ID) + require.Equal(t, "Test Organization", res.Data.Name) + require.True(t, res.Data.CreatedAt.IsPresent()) + require.Equal(t, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), res.Data.CreatedAt.MustGet()) + }, + }, + { + name: "error - organization not found", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Organization not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().GetOrganizationDetails( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + ).Return(client.Result[components.OrganizationDetails]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationDetailsResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationDetailsResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(404), structuredErr.StatusCode().MustGet()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.GetOrganizationDetails(ctx, identifiers.NewOrganizationID(tc.orgID)) + tc.assert(t, res, err) + }) + } +} + +func TestOrganizationsService_ListOrganizationMembers(t *testing.T) { + testCases := []struct { + name string + client func(ctrl *gomock.Controller) client.Client + orgID uuid.UUID + pageSize mo.Option[uint] + maxPages mo.Option[uint] + ctx func() context.Context + assert func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) + }{ + { + name: "success - single page with members", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + CreatedAt: &createdAt, + Email: "user1@example.com", + FirstName: "John", + LastName: "Doe", + Roles: []string{"admin", "viewer"}, + }, + { + UID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + Email: "user2@example.com", + FirstName: "Jane", + LastName: "Smith", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: nil, + PageSize: 10, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Equal(t, 100*time.Millisecond, res.Meta.Latency) + require.Len(t, res.Data.Members, 2) + + // Check first member + require.Equal(t, uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), res.Data.Members[0].ID) + require.True(t, res.Data.Members[0].Email.IsPresent()) + require.Equal(t, "user1@example.com", res.Data.Members[0].Email.MustGet()) + require.True(t, res.Data.Members[0].FirstName.IsPresent()) + require.Equal(t, "John", res.Data.Members[0].FirstName.MustGet()) + require.True(t, res.Data.Members[0].LastName.IsPresent()) + require.Equal(t, "Doe", res.Data.Members[0].LastName.MustGet()) + require.Equal(t, []string{"admin", "viewer"}, res.Data.Members[0].Roles) + + // Check second member + require.Equal(t, uuid.MustParse("b2c3d4e5-f6a7-8901-bcde-f12345678901"), res.Data.Members[1].ID) + require.Equal(t, "user2@example.com", res.Data.Members[1].Email.MustGet()) + require.Equal(t, []string{"viewer"}, res.Data.Members[1].Roles) + }, + }, + { + name: "success - empty members list", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{}, + Pagination: components.PaginationInfo{ + NextPageToken: nil, + PageSize: 10, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 0) + }, + }, + { + name: "success - multiple pages with maxPages", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.Some(uint(2)), + maxPages: mo.Some(uint(2)), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + + // First page + token1 := "token1" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(2), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + { + UID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + Email: "user2@example.com", + FirstName: "User", + LastName: "Two", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token1, + PageSize: 2, + }, + }, + }, nil) + + // Second page + token2 := "token2" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(2), + mo.Some("token1"), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 100 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "c3d4e5f6-a7b8-9012-cdef-123456789012", + Email: "user3@example.com", + FirstName: "User", + LastName: "Three", + Roles: []string{"editor"}, + }, + { + UID: "d4e5f6a7-b8c9-0123-def1-234567890123", + Email: "user4@example.com", + FirstName: "User", + LastName: "Four", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token2, // Has more pages but we stop at maxPages + PageSize: 2, + }, + }, + }, nil) + + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 4) + require.Equal(t, "user1@example.com", res.Data.Members[0].Email.MustGet()) + require.Equal(t, "user2@example.com", res.Data.Members[1].Email.MustGet()) + require.Equal(t, "user3@example.com", res.Data.Members[2].Email.MustGet()) + require.Equal(t, "user4@example.com", res.Data.Members[3].Email.MustGet()) + }, + }, + { + name: "success - pagination until no next token", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.Some(uint(1)), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + + // First page + token1 := "token1" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token1, + PageSize: 1, + }, + }, + }, nil) + + // Second page (last page) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.Some("token1"), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + Email: "user2@example.com", + FirstName: "User", + LastName: "Two", + Roles: []string{"viewer"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: nil, // No more pages + PageSize: 1, + }, + }, + }, nil) + + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 2) + require.Equal(t, "user1@example.com", res.Data.Members[0].Email.MustGet()) + require.Equal(t, "user2@example.com", res.Data.Members[1].Email.MustGet()) + }, + }, + { + name: "success - empty string next token treated as no next page", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + emptyToken := "" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &emptyToken, // Empty string should be treated as no next page + PageSize: 10, + }, + }, + }, nil) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.NoError(t, err) + require.NotNil(t, res.Meta) + require.Len(t, res.Data.Members, 1) + }, + }, + { + name: "error - first page fails", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + detail := "Organization not found" + status := int64(404) + structuredErr := client.NewCensysClientStructuredError(&sdkerrors.ErrorModel{ + Detail: &detail, + Status: &status, + }) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{}, structuredErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var structuredErr client.ClientStructuredError + require.True(t, errors.As(err, &structuredErr)) + require.True(t, structuredErr.StatusCode().IsPresent()) + require.Equal(t, int64(404), structuredErr.StatusCode().MustGet()) + }, + }, + { + name: "error - second page fails", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.Some(uint(1)), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + + // First page succeeds + token1 := "token1" + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{ + Metadata: client.Metadata{ + Request: &http.Request{}, + Response: &http.Response{StatusCode: 200}, + Latency: 50 * time.Millisecond, + Attempts: 1, + }, + Data: &components.OrganizationMembersList{ + Members: []components.OrganizationMember{ + { + UID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Email: "user1@example.com", + FirstName: "User", + LastName: "One", + Roles: []string{"admin"}, + }, + }, + Pagination: components.PaginationInfo{ + NextPageToken: &token1, + PageSize: 1, + }, + }, + }, nil) + + // Second page fails + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Internal server error", + StatusCode: 500, + Body: "Server error", + }) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.Some(1), + mo.Some("token1"), + ).Return(client.Result[components.OrganizationMembersList]{}, genericErr) + + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + // Should return error, not partial results + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(500), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - generic client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + genericErr := client.NewCensysClientGenericError(&sdkerrors.SDKError{ + Message: "Service unavailable", + StatusCode: 503, + Body: "Try again later", + }) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{}, genericErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var genericErr client.ClientGenericError + require.True(t, errors.As(err, &genericErr)) + require.Equal(t, int64(503), genericErr.StatusCode().MustGet()) + }, + }, + { + name: "error - unknown client error", + orgID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + pageSize: mo.None[uint](), + maxPages: mo.None[uint](), + client: func(ctrl *gomock.Controller) client.Client { + mockClient := mocks.NewMockClient(ctrl) + unknownErr := client.NewClientError(errors.New("network timeout")) + mockClient.EXPECT().ListOrganizationMembers( + gomock.Any(), + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + mo.None[int](), + mo.None[string](), + ).Return(client.Result[components.OrganizationMembersList]{}, unknownErr) + return mockClient + }, + ctx: nil, + assert: func(t *testing.T, res OrganizationMembersResult, err cenclierrors.CencliError) { + require.Error(t, err) + require.Equal(t, OrganizationMembersResult{}, res) + + var unknownErr client.ClientError + require.True(t, errors.As(err, &unknownErr)) + require.Contains(t, err.Error(), "network timeout") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + c := tc.client(ctrl) + svc := New(c) + + ctx := context.Background() + if tc.ctx != nil { + ctx = tc.ctx() + } + + res, err := svc.ListOrganizationMembers(ctx, identifiers.NewOrganizationID(tc.orgID), tc.pageSize, tc.maxPages) + tc.assert(t, res, err) + }) + } +} diff --git a/internal/command/context.go b/internal/command/context.go index 8663900..a91e744 100644 --- a/internal/command/context.go +++ b/internal/command/context.go @@ -2,18 +2,25 @@ package command import ( "context" + "errors" "log/slog" "sync" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/censys/cencli/internal/app/aggregate" "github.com/censys/cencli/internal/app/censeye" + "github.com/censys/cencli/internal/app/credits" "github.com/censys/cencli/internal/app/history" + "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/view" "github.com/censys/cencli/internal/config" "github.com/censys/cencli/internal/pkg/cenclierrors" client "github.com/censys/cencli/internal/pkg/clients/censys" + "github.com/censys/cencli/internal/pkg/domain/identifiers" "github.com/censys/cencli/internal/pkg/domain/responsemeta" "github.com/censys/cencli/internal/pkg/formatter" "github.com/censys/cencli/internal/pkg/styles" @@ -34,6 +41,8 @@ type Context struct { aggregateSvc aggregate.Service historySvc history.Service censeyeSvc censeye.Service + creditsSvc credits.Service + orgSvc organizations.Service } // ContextOpts are functional options for configuring Context @@ -74,6 +83,29 @@ func (c *Context) SetLogger(l *slog.Logger) { c.logger = l } // SetClient sets the Context's client so that it can be used to initialize services. func (c *Context) SetCensysClient(cli client.Client) { c.censysClient = cli } +// HasOrgID returns true if the context has a configured organization ID. +func (c *Context) HasOrgID() bool { + return c.censysClient != nil && c.censysClient.HasOrgID() +} + +// GetStoredOrgID retrieves the stored organization ID from the store. +// Returns the org ID if found, or None if not configured. +func (c *Context) GetStoredOrgID(ctx context.Context) (mo.Option[identifiers.OrganizationID], cenclierrors.CencliError) { + zero := mo.None[identifiers.OrganizationID]() + storedOrgID, err := c.store.GetLastUsedGlobalByName(ctx, config.OrgIDGlobalName) + if err != nil { + if errors.Is(err, store.ErrGlobalNotFound) { + return zero, nil + } + return zero, cenclierrors.NewCencliError(err) + } + parsedUUID, parseErr := uuid.Parse(storedOrgID.Value) + if parseErr != nil { + return zero, cenclierrors.NewCencliError(parseErr) + } + return mo.Some(identifiers.NewOrganizationID(parsedUUID)), nil +} + // Logger returns a logger pre-populated with the command name field. func (c *Context) Logger(cmdName string) *slog.Logger { return c.logger.With("cmd", cmdName) @@ -315,3 +347,45 @@ func (c *Context) AggregateService() (aggregate.Service, cenclierrors.CencliErro func WithAggregateService(svc aggregate.Service) ContextOpts { return func(c *Context) { c.aggregateSvc = svc } } + +// CreditsService attempts to provide a CreditsService to the caller. +// If it is not already set and is unable to be instantiated, it will return an error. +func (c *Context) CreditsService() (credits.Service, cenclierrors.CencliError) { + if c.creditsSvc != nil { + return c.creditsSvc, nil + } + if c.censysClient == nil { + return nil, client.NewCensysClientNotConfiguredError() + } + // Memoize the service instance since it's stateless and thread-safe for reuse + c.creditsSvc = credits.New(c.censysClient) + return c.creditsSvc, nil +} + +// WithCreditsService injects an instantiated CreditsService to the Context. +// This should only be used in tests, as in the application, +// the CreditsService will be instantiated on demand. +func WithCreditsService(svc credits.Service) ContextOpts { + return func(c *Context) { c.creditsSvc = svc } +} + +// OrganizationsService attempts to provide an OrganizationsService to the caller. +// If it is not already set and is unable to be instantiated, it will return an error. +func (c *Context) OrganizationsService() (organizations.Service, cenclierrors.CencliError) { + if c.orgSvc != nil { + return c.orgSvc, nil + } + if c.censysClient == nil { + return nil, client.NewCensysClientNotConfiguredError() + } + // Memoize the service instance since it's stateless and thread-safe for reuse + c.orgSvc = organizations.New(c.censysClient) + return c.orgSvc, nil +} + +// WithOrganizationsService injects an instantiated OrganizationsService to the Context. +// This should only be used in tests, as in the application, +// the OrganizationsService will be instantiated on demand. +func WithOrganizationsService(svc organizations.Service) ContextOpts { + return func(c *Context) { c.orgSvc = svc } +} diff --git a/internal/command/credits/credits.go b/internal/command/credits/credits.go new file mode 100644 index 0000000..5d41bf2 --- /dev/null +++ b/internal/command/credits/credits.go @@ -0,0 +1,98 @@ +package credits + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +const cmdName = "credits" + +type Command struct { + *command.BaseCommand + // services the command uses + creditsSvc credits.Service + // result stored for rendering + result credits.UserCreditDetailsResult +} + +var _ command.Command = (*Command)(nil) + +func NewCreditsCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Display credit details for your Censys account" +} + +func (c *Command) Long() string { + return `Display credit details for your Free user Censys account. + +Note: This command only shows free user credits. If you want to see organization credits, +run "censys org credits" instead.` +} + +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.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# Show free user credits", + } +} + +func (c *Command) Init() error { + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.creditsSvc, err = c.CreditsService() + if err != nil { + return err + } + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching user credits...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.creditsSvc.GetUserCreditDetails(pctx) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + return c.showUserCredits(c.result) +} diff --git a/internal/command/credits/credits_test.go b/internal/command/credits/credits_test.go new file mode 100644 index 0000000..a1f5ba2 --- /dev/null +++ b/internal/command/credits/credits_test.go @@ -0,0 +1,173 @@ +package credits + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + creditsmocks "github.com/censys/cencli/gen/app/credits/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/app/credits" + "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" + "github.com/censys/cencli/internal/store" +) + +func TestCreditsCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) credits.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases - free user credits + { + name: "success - default free user credits", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: credits.UserCreditDetails{ + Balance: 500, + ResetsAt: mo.Some(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)), + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "500") + }, + }, + { + name: "success - free user credits with balance and reset date", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 80*time.Millisecond, 1), + Data: credits.UserCreditDetails{ + Balance: 1000, + ResetsAt: mo.Some(time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)), + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "1000") + }, + }, + + // Error cases + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + { + name: "error - user credits service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{}, cenclierrors.NewCencliError(errors.New("unauthorized"))) + return mockSvc + }, + args: []string{}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "unauthorized") + }, + }, + + // Edge cases + { + name: "success - zero balance user credits", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) credits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetUserCreditDetails( + gomock.Any(), + ).Return(credits.UserCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 50*time.Millisecond, 1), + Data: credits.UserCreditDetails{ + Balance: 0, + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, `: 0`) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + creditsSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithCreditsService(creditsSvc)} + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewCreditsCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/credits/short.go b/internal/command/credits/short.go new file mode 100644 index 0000000..43aeafe --- /dev/null +++ b/internal/command/credits/short.go @@ -0,0 +1,39 @@ +package credits + +import ( + "fmt" + "strings" + + "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/formatter/short" + "github.com/censys/cencli/internal/pkg/styles" +) + +func (c *Command) showUserCredits(result credits.UserCreditDetailsResult) cenclierrors.CencliError { + var out strings.Builder + data := result.Data + + // Header + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Your Free User Credit Details ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + // Balance + balanceLabel := styles.GlobalStyles.Primary.Render("Balance") + balanceValue := styles.GlobalStyles.Info.Bold(true).Render(short.FormatNumber(data.Balance)) + fmt.Fprintf(&out, " %s: %s credits", balanceLabel, balanceValue) + + // Resets At + if data.ResetsAt.IsPresent() { + resetTime := data.ResetsAt.MustGet() + resetStr := fmt.Sprintf("(resets %s)", resetTime.Format("2006-01-02")) + fmt.Fprintf(&out, " %s", styles.GlobalStyles.Comment.Render(resetStr)) + } + + out.WriteRune('\n') + formatter.Println(formatter.Stdout, out.String()) + return nil +} diff --git a/internal/command/org/credits/credits.go b/internal/command/org/credits/credits.go new file mode 100644 index 0000000..00b0dc9 --- /dev/null +++ b/internal/command/org/credits/credits.go @@ -0,0 +1,137 @@ +package credits + +import ( + "context" + + "github.com/spf13/cobra" + + appcredits "github.com/censys/cencli/internal/app/credits" + "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 cmdName = "credits" + +// Command displays credit details for an organization. +type Command struct { + *command.BaseCommand + // services + creditsSvc appcredits.Service + // flags + flags creditsFlags + // state + orgID identifiers.OrganizationID + // result + result appcredits.OrganizationCreditDetailsResult +} + +type creditsFlags struct { + orgID flags.OrgIDFlag +} + +var _ command.Command = (*Command)(nil) + +// NewCreditsCommand creates a new org credits command. +func NewCreditsCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Display credit details for your organization" +} + +func (c *Command) Long() string { + return `Display credit details for your organization. + +This command shows your organization's credit balance, auto-replenish configuration, +and any credit expirations. + +By default, the stored organization ID is used. Use --org-id to query a specific organization.` +} + +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.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# Show credits for your stored organization", + "--org-id # Show credits for a specific organization", + } +} + +func (c *Command) Init() error { + c.flags.orgID = flags.NewOrgIDFlag( + c.Flags(), + "", + ) + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.creditsSvc, err = c.CreditsService() + if err != nil { + return err + } + + orgIDFromFlag, err := c.flags.orgID.Value() + if err != nil { + return err + } + if orgIDFromFlag.IsPresent() { + c.orgID = orgIDFromFlag.MustGet() + } else { + storedOrgID, err := c.GetStoredOrgID(cmd.Context()) + if err != nil { + return err + } + if storedOrgID.IsPresent() { + c.orgID = storedOrgID.MustGet() + } + } + // if no org ID is found, return an error + if c.orgID.IsZero() { + return cenclierrors.NewNoOrgIDError() + } + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching organization credits...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.creditsSvc.GetOrganizationCreditDetails(pctx, c.orgID) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + return c.showOrgCredits(c.result) +} diff --git a/internal/command/org/credits/credits_test.go b/internal/command/org/credits/credits_test.go new file mode 100644 index 0000000..5b31c19 --- /dev/null +++ b/internal/command/org/credits/credits_test.go @@ -0,0 +1,287 @@ +package credits + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + creditsmocks "github.com/censys/cencli/gen/app/credits/mocks" + clientmocks "github.com/censys/cencli/gen/client/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + appcredits "github.com/censys/cencli/internal/app/credits" + "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/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestOrgCreditsCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) appcredits.Service + client func(ctrl *gomock.Controller) *clientmocks.MockClient + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases + { + name: "success - org credits with --org-id flag", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 5000, + CreditExpirations: []appcredits.CreditExpiration{}, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "5000") + }, + }, + { + name: "success - org credits with stored org ID", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(&store.ValueForGlobal{ + ID: 1, + Name: "org-id", + Value: "58857aac-4b76-46ec-a567-0e02b2c3d479", + }, nil) + return mockStore + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 150*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 7500, + CreditExpirations: []appcredits.CreditExpiration{}, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "7500") + }, + }, + { + name: "success - org credits with credit expirations", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 3000, + CreditExpirations: []appcredits.CreditExpiration{ + { + Balance: 1500, + CreationDate: mo.Some(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + ExpirationDate: mo.Some(time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)), + }, + }, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: false, + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "3000") + require.Contains(t, stdout, `"credit_expirations"`) + require.Contains(t, stdout, "1500") + }, + }, + { + name: "success - org credits with auto-replenish enabled", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: appcredits.OrganizationCreditDetails{ + Balance: 2000, + CreditExpirations: []appcredits.CreditExpiration{}, + AutoReplenishConfig: appcredits.AutoReplenishConfig{ + Enabled: true, + Threshold: mo.Some(int64(100)), + Amount: mo.Some(int64(500)), + }, + }, + }, nil) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"balance"`) + require.Contains(t, stdout, "2000") + require.Contains(t, stdout, `"auto_replenish_config"`) + require.Contains(t, stdout, `"enabled": true`) + }, + }, + + // Error cases + { + name: "error - no org ID configured", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(nil, store.ErrGlobalNotFound) + return mockStore + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + client: nil, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no organization ID configured") + }, + }, + { + name: "error - invalid org ID format", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + client: nil, + args: []string{"--org-id", "invalid-uuid", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "error - service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + mockSvc := creditsmocks.NewMockCreditsService(ctrl) + mockSvc.EXPECT().GetOrganizationCreditDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(appcredits.OrganizationCreditDetailsResult{}, cenclierrors.NewCencliError(errors.New("organization not found"))) + return mockSvc + }, + client: nil, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "organization not found") + }, + }, + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) appcredits.Service { + return creditsmocks.NewMockCreditsService(ctrl) + }, + client: nil, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + creditsSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithCreditsService(creditsSvc)} + + if tc.client != nil { + mockClient := tc.client(ctrl) + opts = append(opts, func(c *command.Context) { + c.SetCensysClient(mockClient) + }) + } + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewCreditsCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/org/credits/short.go b/internal/command/org/credits/short.go new file mode 100644 index 0000000..f177810 --- /dev/null +++ b/internal/command/org/credits/short.go @@ -0,0 +1,75 @@ +package credits + +import ( + "fmt" + "strings" + + appcredits "github.com/censys/cencli/internal/app/credits" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/formatter/short" + "github.com/censys/cencli/internal/pkg/styles" +) + +func (c *Command) showOrgCredits(result appcredits.OrganizationCreditDetailsResult) cenclierrors.CencliError { + var out strings.Builder + data := result.Data + + // Header + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Organization Credit Details ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + // Balance - big and bold + balanceLabel := styles.GlobalStyles.Primary.Render("Balance") + balanceValue := styles.GlobalStyles.Info.Bold(true).Render(short.FormatNumber(data.Balance)) + fmt.Fprintf(&out, " %s: %s credits\n", balanceLabel, balanceValue) + + // Auto Replenish Config + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Primary.Render(" Auto Replenish")) + out.WriteRune('\n') + + if data.AutoReplenishConfig.Enabled { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Status"), + styles.GlobalStyles.Secondary.Render("✓ Enabled")) + if data.AutoReplenishConfig.Threshold.IsPresent() { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Threshold"), + styles.GlobalStyles.Tertiary.Render(short.FormatNumber(data.AutoReplenishConfig.Threshold.MustGet()))) + } + if data.AutoReplenishConfig.Amount.IsPresent() { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Amount"), + styles.GlobalStyles.Tertiary.Render(short.FormatNumber(data.AutoReplenishConfig.Amount.MustGet()))) + } + } else { + fmt.Fprintf(&out, " %s: %s\n", + styles.GlobalStyles.Comment.Render("Status"), + styles.GlobalStyles.Comment.Render("✗ Disabled")) + } + + // Credit Expirations + if len(data.CreditExpirations) > 0 { + out.WriteString("\n") + out.WriteString(styles.GlobalStyles.Primary.Render(fmt.Sprintf(" Credit Expirations (%d)", len(data.CreditExpirations)))) + out.WriteString("\n") + + for _, exp := range data.CreditExpirations { + expBalance := styles.GlobalStyles.Info.Render(short.FormatNumber(exp.Balance)) + fmt.Fprintf(&out, " %s %s credits", styles.GlobalStyles.Tertiary.Render("•"), expBalance) + + if exp.ExpirationDate.IsPresent() { + expDate := exp.ExpirationDate.MustGet() + expStr := fmt.Sprintf("(expires %s)", expDate.Format("2006-01-02")) + fmt.Fprintf(&out, " %s", styles.GlobalStyles.Comment.Render(expStr)) + } + out.WriteString("\n") + } + } + + formatter.Println(formatter.Stdout, out.String()) + return nil +} diff --git a/internal/command/org/details/details.go b/internal/command/org/details/details.go new file mode 100644 index 0000000..a476da9 --- /dev/null +++ b/internal/command/org/details/details.go @@ -0,0 +1,138 @@ +package details + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/organizations" + "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 cmdName = "details" + +// Command displays organization details. +type Command struct { + *command.BaseCommand + // services + orgSvc organizations.Service + // flags + flags detailsFlags + // state + orgID identifiers.OrganizationID + // result + result organizations.OrganizationDetailsResult +} + +type detailsFlags struct { + orgID flags.OrgIDFlag +} + +var _ command.Command = (*Command)(nil) + +// NewDetailsCommand creates a new org details command. +func NewDetailsCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "Display organization details" +} + +func (c *Command) Long() string { + return `Display details about your organization. + +This command shows organization information including name, ID, creation date, +and member counts. + +By default, the stored organization ID is used. Use --org-id to query a specific organization.` +} + +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.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# Show details for your stored organization", + "--org-id # Show details for a specific organization", + "--output-format json # Output as JSON", + } +} + +func (c *Command) Init() error { + c.flags.orgID = flags.NewOrgIDFlag( + c.Flags(), + "", + ) + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgSvc, err = c.OrganizationsService() + if err != nil { + return err + } + + orgIDFromFlag, err := c.flags.orgID.Value() + if err != nil { + return err + } + if orgIDFromFlag.IsPresent() { + c.orgID = orgIDFromFlag.MustGet() + } else { + storedOrgID, err := c.GetStoredOrgID(cmd.Context()) + if err != nil { + return err + } + if storedOrgID.IsPresent() { + c.orgID = storedOrgID.MustGet() + } + } + // if no org ID is found, return an error + if c.orgID.IsZero() { + return cenclierrors.NewNoOrgIDError() + } + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching organization details...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.orgSvc.GetOrganizationDetails(pctx, c.orgID) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + return c.showOrgDetails(c.result) +} diff --git a/internal/command/org/details/details_test.go b/internal/command/org/details/details_test.go new file mode 100644 index 0000000..cdff1b2 --- /dev/null +++ b/internal/command/org/details/details_test.go @@ -0,0 +1,261 @@ +package details + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + orgmocks "github.com/censys/cencli/gen/app/organizations/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/app/organizations" + "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/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestOrgDetailsCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) organizations.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases + { + name: "success - get details with --org-id flag", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + adminCount := int64(3) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + CreatedAt: mo.Some(createdAt), + Name: "Test Organization", + MemberCounts: &components.MemberCounts{ + Total: 10, + ByRole: components.ByRole{ + Admin: &adminCount, + }, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"name"`) + require.Contains(t, stdout, "Test Organization") + require.Contains(t, stdout, "f47ac10b-58cc-4372-a567-0e02b2c3d479") + }, + }, + { + name: "success - get details with stored org ID", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(&store.ValueForGlobal{ + ID: 1, + Name: "org-id", + Value: "58857aac-4b76-46ec-a567-0e02b2c3d479", + }, nil) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479"), + Name: "My Organization", + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "My Organization") + }, + }, + { + name: "success - get details with minimal data", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 50*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + Name: "Minimal Org", + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Minimal Org") + }, + }, + { + name: "success - get details with preferences", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mfaRequired := true + aiOptIn := false + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationDetails{ + ID: uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + Name: "Org With Preferences", + Preferences: &components.OrganizationPreferences{ + MfaRequired: &mfaRequired, + AiOptIn: &aiOptIn, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "Org With Preferences") + require.Contains(t, stdout, "preferences") + }, + }, + + // Error cases + { + name: "error - no org ID configured", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(nil, store.ErrGlobalNotFound) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no organization ID configured") + }, + }, + { + name: "error - invalid org ID format", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--org-id", "invalid-uuid", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "error - service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().GetOrganizationDetails( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + ).Return(organizations.OrganizationDetailsResult{}, cenclierrors.NewCencliError(errors.New("organization not found"))) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "organization not found") + }, + }, + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + orgSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithOrganizationsService(orgSvc)} + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewDetailsCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/org/details/short.go b/internal/command/org/details/short.go new file mode 100644 index 0000000..76794e9 --- /dev/null +++ b/internal/command/org/details/short.go @@ -0,0 +1,109 @@ +package details + +import ( + "fmt" + "strings" + + "github.com/censys/cencli/internal/app/organizations" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/styles" +) + +func (c *Command) showOrgDetails(result organizations.OrganizationDetailsResult) cenclierrors.CencliError { + var out strings.Builder + data := result.Data + + // Header + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Signature.Render("━━━ Organization Details ━━━")) + out.WriteRune('\n') + out.WriteRune('\n') + + // Organization Name - big and bold + nameLabel := fmt.Sprintf("%-8s", "Name:") + nameLabelStyled := styles.GlobalStyles.Primary.Render(nameLabel) + nameValue := styles.GlobalStyles.Info.Bold(true).Render(data.Name) + fmt.Fprintf(&out, " %s %s\n", nameLabelStyled, nameValue) + + // Organization ID + idLabel := fmt.Sprintf("%-8s", "ID:") + idLabelStyled := styles.GlobalStyles.Primary.Render(idLabel) + idValue := styles.GlobalStyles.Comment.Render(data.ID.String()) + fmt.Fprintf(&out, " %s %s\n", idLabelStyled, idValue) + + // Created At + if data.CreatedAt.IsPresent() { + createdLabel := fmt.Sprintf("%-8s", "Created:") + createdLabelStyled := styles.GlobalStyles.Primary.Render(createdLabel) + createdValue := styles.GlobalStyles.Comment.Render(data.CreatedAt.MustGet().Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(&out, " %s %s\n", createdLabelStyled, createdValue) + } + + // Member Counts + if data.MemberCounts != nil { + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Primary.Render(" Member Counts")) + out.WriteRune('\n') + + totalLabel := fmt.Sprintf("%-11s", "Total:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(totalLabel), + styles.GlobalStyles.Tertiary.Render(fmt.Sprintf("%d", data.MemberCounts.Total))) + + // Show role breakdown if available + if data.MemberCounts.ByRole.Admin != nil { + adminsLabel := fmt.Sprintf("%-11s", "Admins:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(adminsLabel), + styles.GlobalStyles.Tertiary.Render(fmt.Sprintf("%d", *data.MemberCounts.ByRole.Admin))) + } + if data.MemberCounts.ByRole.APIAccess != nil { + apiAccessLabel := fmt.Sprintf("%-11s", "API Access:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(apiAccessLabel), + styles.GlobalStyles.Tertiary.Render(fmt.Sprintf("%d", *data.MemberCounts.ByRole.APIAccess))) + } + } + + // Preferences + if data.Preferences != nil { + out.WriteRune('\n') + out.WriteString(styles.GlobalStyles.Primary.Render(" Preferences")) + out.WriteRune('\n') + + if data.Preferences.MfaRequired != nil { + mfaStatus := "Disabled" + if *data.Preferences.MfaRequired { + mfaStatus = "Required" + } + mfaLabel := fmt.Sprintf("%-12s", "MFA:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(mfaLabel), + styles.GlobalStyles.Tertiary.Render(mfaStatus)) + } + if data.Preferences.AiOptIn != nil { + aiStatus := "Opted Out" + if *data.Preferences.AiOptIn { + aiStatus = "Opted In" + } + aiFeaturesLabel := fmt.Sprintf("%-12s", "AI Features:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(aiFeaturesLabel), + styles.GlobalStyles.Tertiary.Render(aiStatus)) + } + if data.Preferences.AiTraining != nil { + aiTrainingStatus := "Disabled" + if *data.Preferences.AiTraining { + aiTrainingStatus = "Enabled" + } + aiTrainingLabel := fmt.Sprintf("%-12s", "AI Training:") + fmt.Fprintf(&out, " %s %s\n", + styles.GlobalStyles.Comment.Render(aiTrainingLabel), + styles.GlobalStyles.Tertiary.Render(aiTrainingStatus)) + } + } + + formatter.Println(formatter.Stdout, out.String()) + return nil +} diff --git a/internal/command/org/members/members.go b/internal/command/org/members/members.go new file mode 100644 index 0000000..29e007c --- /dev/null +++ b/internal/command/org/members/members.go @@ -0,0 +1,164 @@ +package members + +import ( + "context" + + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/organizations" + "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 cmdName = "members" + +// Command displays organization members. +type Command struct { + *command.BaseCommand + // services + orgSvc organizations.Service + // flags + flags membersFlags + // state + orgID identifiers.OrganizationID + interactive bool + // result + result organizations.OrganizationMembersResult +} + +type membersFlags struct { + orgID flags.OrgIDFlag + interactive flags.BoolFlag +} + +var _ command.Command = (*Command)(nil) + +// NewMembersCommand creates a new org members command. +func NewMembersCommand(cmdContext *command.Context) *Command { + return &Command{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *Command) Use() string { + return cmdName +} + +func (c *Command) Short() string { + return "List organization members" +} + +func (c *Command) Long() string { + return `List members in your organization. + +This command displays all members including their email, name, roles, and last login time. + +By default, the stored organization ID is used. Use --org-id to query a specific organization. +Use --interactive for a navigable table view.` +} + +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.OutputTypeData, command.OutputTypeShort} +} + +func (c *Command) Examples() []string { + return []string{ + "# List members for your stored organization", + "--interactive # List members in an interactive table", + "--org-id # List members for a specific organization", + "--output-format json # Output as JSON", + } +} + +func (c *Command) Init() error { + c.flags.orgID = flags.NewOrgIDFlag( + c.Flags(), + "", + ) + c.flags.interactive = flags.NewBoolFlag( + c.Flags(), + "interactive", + "i", + false, + "display results in an interactive table (TUI)", + ) + return nil +} + +func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.orgSvc, err = c.OrganizationsService() + if err != nil { + return err + } + + orgIDFromFlag, err := c.flags.orgID.Value() + if err != nil { + return err + } + if orgIDFromFlag.IsPresent() { + c.orgID = orgIDFromFlag.MustGet() + } else { + storedOrgID, err := c.GetStoredOrgID(cmd.Context()) + if err != nil { + return err + } + if storedOrgID.IsPresent() { + c.orgID = storedOrgID.MustGet() + } + } + // if no org ID is found, return an error + if c.orgID.IsZero() { + return cenclierrors.NewNoOrgIDError() + } + + // Get interactive flag + c.interactive, err = c.flags.interactive.Value() + if err != nil { + return err + } + + return nil +} + +func (c *Command) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + err := c.WithProgress( + cmd.Context(), + c.Logger(cmdName), + "Fetching organization members...", + func(pctx context.Context) cenclierrors.CencliError { + var fetchErr cenclierrors.CencliError + c.result, fetchErr = c.orgSvc.ListOrganizationMembers( + pctx, + c.orgID, + mo.None[uint](), // pageSize - get all + mo.None[uint](), // maxPages - no limit + ) + return fetchErr + }, + ) + if err != nil { + return err + } + + c.PrintAppResponseMeta(c.result.Meta) + return c.PrintData(c, c.result.Data) +} + +func (c *Command) RenderShort() cenclierrors.CencliError { + if c.interactive { + return c.showInteractiveTable(c.result) + } + return c.showRawTable(c.result) +} diff --git a/internal/command/org/members/members_test.go b/internal/command/org/members/members_test.go new file mode 100644 index 0000000..14f4bfc --- /dev/null +++ b/internal/command/org/members/members_test.go @@ -0,0 +1,251 @@ +package members + +import ( + "bytes" + "errors" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + orgmocks "github.com/censys/cencli/gen/app/organizations/mocks" + storemocks "github.com/censys/cencli/gen/store/mocks" + "github.com/censys/cencli/internal/app/organizations" + "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/identifiers" + "github.com/censys/cencli/internal/pkg/domain/responsemeta" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +func TestOrgMembersCommand(t *testing.T) { + testCases := []struct { + name string + store func(ctrl *gomock.Controller) store.Store + service func(ctrl *gomock.Controller) organizations.Service + args []string + assert func(t *testing.T, stdout, stderr string, err error) + }{ + // Success cases + { + name: "success - list members with --org-id flag", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + createdAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + lastLogin := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationMembers{ + Members: []organizations.OrganizationMember{ + { + ID: uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + CreatedAt: mo.Some(createdAt), + Email: mo.Some("user1@example.com"), + FirstName: mo.Some("John"), + LastName: mo.Some("Doe"), + Roles: []string{"admin", "viewer"}, + LatestLoginTime: mo.Some(lastLogin), + }, + { + ID: uuid.MustParse("b2c3d4e5-f6a7-8901-bcde-f12345678901"), + Email: mo.Some("user2@example.com"), + FirstName: mo.Some("Jane"), + LastName: mo.Some("Smith"), + Roles: []string{"viewer"}, + }, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"members"`) + require.Contains(t, stdout, "user1@example.com") + require.Contains(t, stdout, "user2@example.com") + require.Contains(t, stdout, "John") + require.Contains(t, stdout, "admin") + }, + }, + { + name: "success - list members with stored org ID", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(&store.ValueForGlobal{ + ID: 1, + Name: "org-id", + Value: "58857aac-4b76-46ec-a567-0e02b2c3d479", + }, nil) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("58857aac-4b76-46ec-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 100*time.Millisecond, 1), + Data: organizations.OrganizationMembers{ + Members: []organizations.OrganizationMember{ + { + ID: uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + Email: mo.Some("user@example.com"), + Roles: []string{"admin"}, + }, + }, + }, + }, nil) + return mockSvc + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, "user@example.com") + }, + }, + { + name: "success - empty members list", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{ + Meta: responsemeta.NewResponseMeta(&http.Request{}, &http.Response{StatusCode: 200}, 50*time.Millisecond, 1), + Data: organizations.OrganizationMembers{ + Members: []organizations.OrganizationMember{}, + }, + }, nil) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.NoError(t, err) + require.Contains(t, stdout, `"members"`) + }, + }, + + // Error cases + { + name: "error - no org ID configured", + store: func(ctrl *gomock.Controller) store.Store { + mockStore := storemocks.NewMockStore(ctrl) + mockStore.EXPECT().GetLastUsedGlobalByName( + gomock.Any(), + "org-id", + ).Return(nil, store.ErrGlobalNotFound) + return mockStore + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "no organization ID configured") + }, + }, + { + name: "error - invalid org ID format", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"--org-id", "invalid-uuid", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uuid") + }, + }, + { + name: "error - service returns error", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + mockSvc := orgmocks.NewMockOrganizationsService(ctrl) + mockSvc.EXPECT().ListOrganizationMembers( + gomock.Any(), + identifiers.NewOrganizationID(uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")), + mo.None[uint](), + mo.None[uint](), + ).Return(organizations.OrganizationMembersResult{}, cenclierrors.NewCencliError(errors.New("organization not found"))) + return mockSvc + }, + args: []string{"--org-id", "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "organization not found") + }, + }, + { + name: "error - too many arguments", + store: func(ctrl *gomock.Controller) store.Store { + return storemocks.NewMockStore(ctrl) + }, + service: func(ctrl *gomock.Controller) organizations.Service { + return orgmocks.NewMockOrganizationsService(ctrl) + }, + args: []string{"extra-arg", "--output-format", "json"}, + assert: func(t *testing.T, stdout, stderr string, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "accepts 0 arg(s), received 1") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + viper.Reset() + cfg, err := config.New(tempDir) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + formatter.Stdout = &stdout + formatter.Stderr = &stderr + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + orgSvc := tc.service(ctrl) + opts := []command.ContextOpts{command.WithOrganizationsService(orgSvc)} + + cmdContext := command.NewCommandContext(cfg, tc.store(ctrl), opts...) + rootCmd, err := command.RootCommandToCobra(NewMembersCommand(cmdContext)) + require.NoError(t, err) + + rootCmd.SetArgs(tc.args) + execErr := rootCmd.Execute() + tc.assert(t, stdout.String(), stderr.String(), cenclierrors.NewCencliError(execErr)) + }) + } +} diff --git a/internal/command/org/members/short.go b/internal/command/org/members/short.go new file mode 100644 index 0000000..713e541 --- /dev/null +++ b/internal/command/org/members/short.go @@ -0,0 +1,146 @@ +package members + +import ( + "fmt" + "strings" + + "github.com/censys/cencli/internal/app/organizations" + "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" + "github.com/censys/cencli/internal/pkg/ui/table" +) + +func (c *Command) showRawTable(result organizations.OrganizationMembersResult) cenclierrors.CencliError { + if len(result.Data.Members) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo members found.\n") + return nil + } + + columns := []rawtable.Column[organizations.OrganizationMember]{ + { + Title: "Email", + String: func(m organizations.OrganizationMember) string { + if m.Email.IsPresent() { + return m.Email.MustGet() + } + return "-" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorTeal).Render(s) + }, + }, + { + Title: "Name", + String: formatName, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorOffWhite).Render(s) + }, + }, + { + Title: "Roles", + String: func(m organizations.OrganizationMember) string { + if len(m.Roles) > 0 { + return strings.Join(m.Roles, ", ") + } + return "-" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorSage).Render(s) + }, + }, + { + Title: "First Login", + String: func(m organizations.OrganizationMember) string { + if m.FirstLoginTime.IsPresent() { + return m.FirstLoginTime.MustGet().Format("2006-01-02 15:04") + } + return "Never" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + { + Title: "Last Login", + String: func(m organizations.OrganizationMember) string { + if m.LatestLoginTime.IsPresent() { + return m.LatestLoginTime.MustGet().Format("2006-01-02 15:04") + } + return "Never" + }, + Style: func(s string, m organizations.OrganizationMember) string { + return styles.NewStyle(styles.ColorGray).Render(s) + }, + }, + } + + tbl := rawtable.New( + columns, + rawtable.WithHeaderStyle[organizations.OrganizationMember](styles.NewStyle(styles.ColorOffWhite).Bold(true)), + rawtable.WithStylesDisabled[organizations.OrganizationMember](!formatter.StdoutIsTTY()), + ) + + title := styles.GlobalStyles.Signature.Bold(true).Render(fmt.Sprintf("Organization Members (%d)", len(result.Data.Members))) + fmt.Fprintf(formatter.Stdout, "\n%s\n\n", title) + fmt.Fprint(formatter.Stdout, tbl.Render(result.Data.Members)) + fmt.Fprintf(formatter.Stdout, "\n") + + return nil +} + +func (c *Command) showInteractiveTable(result organizations.OrganizationMembersResult) cenclierrors.CencliError { + if len(result.Data.Members) == 0 { + fmt.Fprintf(formatter.Stdout, "\nNo members found.\n") + return nil + } + + tbl := table.NewTable[organizations.OrganizationMember]( + []string{"Email", "Name", "Roles", "First Login", "Last Login"}, + func(m organizations.OrganizationMember) []string { + email := "-" + if m.Email.IsPresent() { + email = m.Email.MustGet() + } + name := formatName(m) + roles := "-" + if len(m.Roles) > 0 { + roles = strings.Join(m.Roles, ", ") + } + firstLogin := "Never" + if m.FirstLoginTime.IsPresent() { + firstLogin = m.FirstLoginTime.MustGet().Format("2006-01-02 15:04") + } + lastLogin := "Never" + if m.LatestLoginTime.IsPresent() { + lastLogin = m.LatestLoginTime.MustGet().Format("2006-01-02 15:04") + } + return []string{email, name, roles, firstLogin, lastLogin} + }, + table.WithColumnWidths[organizations.OrganizationMember]([]int{30, 25, 20, 20, 20}), + table.WithTitle[organizations.OrganizationMember](fmt.Sprintf("Organization Members (%d)", len(result.Data.Members))), + ) + + if err := tbl.Run(result.Data.Members); err != nil { + return cenclierrors.NewCencliError( + fmt.Errorf("failed to display interactive table: %w", err), + ) + } + return nil +} + +// formatName combines first and last name, handling optional values. +func formatName(m organizations.OrganizationMember) string { + var parts []string + if m.FirstName.IsPresent() { + parts = append(parts, m.FirstName.MustGet()) + } + if m.LastName.IsPresent() { + parts = append(parts, m.LastName.MustGet()) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, " ") +} diff --git a/internal/command/org/org.go b/internal/command/org/org.go new file mode 100644 index 0000000..ea72835 --- /dev/null +++ b/internal/command/org/org.go @@ -0,0 +1,72 @@ +package org + +import ( + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/command/org/credits" + "github.com/censys/cencli/internal/command/org/details" + "github.com/censys/cencli/internal/command/org/members" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +// Command is the parent org command that groups organization-related subcommands. +type Command struct { + *command.BaseCommand +} + +var _ command.Command = (*Command)(nil) + +// NewOrgCommand creates a new org command with all subcommands. +func NewOrgCommand(cmdContext *command.Context) *Command { + return &Command{BaseCommand: command.NewBaseCommand(cmdContext)} +} + +func (c *Command) Use() string { + return "org" +} + +func (c *Command) Short() string { + return "Manage and view organization details" +} + +func (c *Command) Long() string { + return `Manage and view organization details including credits, members, and organization information. + +By default, these commands use your stored organization ID. If no organization ID is stored, +or you want to query a different organization, use the --org-id flag on each subcommand. + +To set your default organization ID, run: censys config org-id set ` +} + +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( + credits.NewCreditsCommand(c.Context), + members.NewMembersCommand(c.Context), + details.NewDetailsCommand(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/output.go b/internal/command/output.go index cb03a90..13a2c2d 100644 --- a/internal/command/output.go +++ b/internal/command/output.go @@ -209,7 +209,7 @@ func newUnsupportedOutputFormatError(provided string, supported []string) *unsup } func (e *unsupportedOutputFormatError) Error() string { - return fmt.Sprintf("output format '%q' is not supported by this command -- supported formats: %s", e.provided, strings.Join(e.supported, ", ")) + return fmt.Sprintf("output format '%s' is not supported by this command -- supported formats: %s", e.provided, strings.Join(e.supported, ", ")) } func (e *unsupportedOutputFormatError) Title() string { diff --git a/internal/command/root/root.go b/internal/command/root/root.go index c5fb773..051e142 100644 --- a/internal/command/root/root.go +++ b/internal/command/root/root.go @@ -12,7 +12,9 @@ import ( censeyecmd "github.com/censys/cencli/internal/command/censeye" completioncmd "github.com/censys/cencli/internal/command/completion" configcmd "github.com/censys/cencli/internal/command/config" + creditscmd "github.com/censys/cencli/internal/command/credits" historycmd "github.com/censys/cencli/internal/command/history" + orgcmd "github.com/censys/cencli/internal/command/org" searchcmd "github.com/censys/cencli/internal/command/search" versioncmd "github.com/censys/cencli/internal/command/versioncmd" "github.com/censys/cencli/internal/command/view" @@ -68,6 +70,8 @@ func (c *Command) Init() error { searchcmd.NewSearchCommand(c.Context), aggregatecmd.NewAggregateCommand(c.Context), censeyecmd.NewCenseyeCommand(c.Context), + creditscmd.NewCreditsCommand(c.Context), + orgcmd.NewOrgCommand(c.Context), ) } diff --git a/internal/pkg/cenclierrors/errs.go b/internal/pkg/cenclierrors/errs.go index 8b685c2..9b0df8a 100644 --- a/internal/pkg/cenclierrors/errs.go +++ b/internal/pkg/cenclierrors/errs.go @@ -209,3 +209,21 @@ func IsInterrupted(err error) bool { } return errors.Is(err, context.Canceled) } + +type noOrgIDError struct{} + +func (e *noOrgIDError) Error() string { + return "no organization ID configured. Use --org-id flag or run 'censys config org-id set ' to set a default" +} + +func (e *noOrgIDError) Title() string { + return "No Organization ID" +} + +func (e *noOrgIDError) ShouldPrintUsage() bool { + return true +} + +func NewNoOrgIDError() CencliError { + return &noOrgIDError{} +} diff --git a/internal/pkg/clients/censys/account_management.go b/internal/pkg/clients/censys/account_management.go new file mode 100644 index 0000000..bc77990 --- /dev/null +++ b/internal/pkg/clients/censys/account_management.go @@ -0,0 +1,160 @@ +package censys + +import ( + "context" + "time" + + "github.com/samber/mo" + + "github.com/censys/censys-sdk-go/models/components" + "github.com/censys/censys-sdk-go/models/operations" +) + +//go:generate mockgen -destination=../../../../gen/client/mocks/accountmanagement_mock.go -package=mocks github.com/censys/cencli/internal/pkg/clients/censys AccountManagementClient +type AccountManagementClient interface { + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#getorganizationcredits + GetOrganizationCreditDetails( + ctx context.Context, + orgID string, + ) (Result[components.OrganizationCredits], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#getusercredits + GetUserCreditDetails( + ctx context.Context, + ) (Result[components.UserCredits], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#getorganizationdetails + GetOrganizationDetails( + ctx context.Context, + orgID string, + ) (Result[components.OrganizationDetails], ClientError) + // https://github.com/censys/censys-sdk-go/tree/main/docs/sdks/accountmanagement#listorganizationmembers + ListOrganizationMembers( + ctx context.Context, + orgID string, + pageSize mo.Option[int], + pageToken mo.Option[string], + ) (Result[components.OrganizationMembersList], ClientError) +} + +type accountManagementSDK struct { + *censysSDK +} + +var _ AccountManagementClient = &accountManagementSDK{} + +func newAccountManagementSDK(censysSDK *censysSDK) *accountManagementSDK { + return &accountManagementSDK{censysSDK: censysSDK} +} + +func (a *accountManagementSDK) GetOrganizationCreditDetails( + ctx context.Context, + orgID string, +) (Result[components.OrganizationCredits], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementOrgCreditsResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.GetOrganizationCredits(ctx, operations.V3AccountmanagementOrgCreditsRequest{ + OrganizationID: orgID, + }) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.OrganizationCredits]{} + return zero, err + } + return Result[components.OrganizationCredits]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeOrganizationCredits().GetResult(), + }, nil +} + +func (a *accountManagementSDK) GetUserCreditDetails( + ctx context.Context, +) (Result[components.UserCredits], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementUserCreditsResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.GetUserCredits(ctx) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.UserCredits]{} + return zero, err + } + return Result[components.UserCredits]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeUserCredits().GetResult(), + }, nil +} + +func (a *accountManagementSDK) GetOrganizationDetails( + ctx context.Context, + orgID string, +) (Result[components.OrganizationDetails], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementOrgDetailsResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.GetOrganizationDetails(ctx, operations.V3AccountmanagementOrgDetailsRequest{ + OrganizationID: orgID, + IncludeMemberCounts: boolPtr(true), + }) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.OrganizationDetails]{} + return zero, err + } + return Result[components.OrganizationDetails]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeOrganizationDetails().GetResult(), + }, nil +} + +func (a *accountManagementSDK) ListOrganizationMembers( + ctx context.Context, + orgID string, + pageSize mo.Option[int], + pageToken mo.Option[string], +) (Result[components.OrganizationMembersList], ClientError) { + start := time.Now() + var res *operations.V3AccountmanagementListOrgMembersResponse + err, attempts := a.executeWithRetry(ctx, func() ClientError { + var err error + res, err = a.censysSDK.client.AccountManagement.ListOrganizationMembers(ctx, operations.V3AccountmanagementListOrgMembersRequest{ + OrganizationID: orgID, + PageSize: pageSize.ToPointer(), + PageToken: pageToken.ToPointer(), + }) + if err != nil { + return NewClientError(err) + } + return nil + }) + latency := time.Since(start) + if err != nil { + zero := Result[components.OrganizationMembersList]{} + return zero, err + } + return Result[components.OrganizationMembersList]{ + Metadata: buildResponseMetadata(res, latency, attempts), + Data: res.GetResponseEnvelopeOrganizationMembersList().GetResult(), + }, nil +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/pkg/clients/censys/censys.go b/internal/pkg/clients/censys/censys.go index fbb05a6..b899a47 100644 --- a/internal/pkg/clients/censys/censys.go +++ b/internal/pkg/clients/censys/censys.go @@ -22,6 +22,7 @@ type Client interface { GlobalDataClient CollectionsClient ThreatHuntingClient + AccountManagementClient HasOrgID() bool } @@ -40,6 +41,7 @@ type censysSDKImpl struct { GlobalDataClient CollectionsClient ThreatHuntingClient + AccountManagementClient } var _ Client = &censysSDKImpl{} @@ -79,10 +81,11 @@ func NewCensysSDK( } return &censysSDKImpl{ - censysSDK: censysSDK, - GlobalDataClient: newGlobalDataSDK(censysSDK), - CollectionsClient: newCollectionsSDK(censysSDK), - ThreatHuntingClient: newThreatHuntingSDK(censysSDK), + censysSDK: censysSDK, + GlobalDataClient: newGlobalDataSDK(censysSDK), + CollectionsClient: newCollectionsSDK(censysSDK), + ThreatHuntingClient: newThreatHuntingSDK(censysSDK), + AccountManagementClient: newAccountManagementSDK(censysSDK), }, nil } diff --git a/internal/pkg/domain/identifiers/org.go b/internal/pkg/domain/identifiers/org.go index e68070c..27ed84a 100644 --- a/internal/pkg/domain/identifiers/org.go +++ b/internal/pkg/domain/identifiers/org.go @@ -9,6 +9,8 @@ type OrganizationID struct{ value uuid.UUID } func (i OrganizationID) String() string { return i.value.String() } +func (i OrganizationID) IsZero() bool { return i.value == uuid.Nil } + func NewOrganizationID(uuid uuid.UUID) OrganizationID { return OrganizationID{value: uuid} } diff --git a/internal/pkg/formatter/json.go b/internal/pkg/formatter/json.go index 59af2d0..eb983e5 100644 --- a/internal/pkg/formatter/json.go +++ b/internal/pkg/formatter/json.go @@ -9,14 +9,39 @@ import ( ) // PrintJSON prints v as pretty JSON, optionally colored. +// Uses the standard library for marshaling (to support omitzero), +// then colorizes the output if requested. func PrintJSON(v any, colored bool) error { - enc := newEncoderForWriter(Stdout, colored, true) - return enc.Encode(v) + return writeJSON(Stdout, v, colored, true) } -// jsonEncoder is a type that can encode JSON. -type jsonEncoder interface { - Encode(v any) error +// writeJSON writes v as JSON to w, optionally colored and pretty-printed. +// Uses the standard library for marshaling (to support omitzero), +// then colorizes the output if requested. +func writeJSON(w io.Writer, v any, colored, pretty bool) error { + var data []byte + var err error + if pretty { + data, err = json.MarshalIndent(v, "", " ") + } else { + data, err = json.Marshal(v) + } + if err != nil { + return err + } + + if colored { + // Re-encode the raw JSON with colors + enc := jsoncolor.NewEncoder(w) + enc.SetColors(jsonColors()) + if pretty { + enc.SetIndent("", " ") + } + return enc.Encode(json.RawMessage(data)) + } + + _, err = w.Write(append(data, '\n')) + return err } // jsonColors defines the color scheme for jsoncolor. @@ -38,26 +63,8 @@ func jsonColors() *jsoncolor.Colors { // WriteNDJSONItem encodes a single item as NDJSON (one line of JSON) to the provided writer. // This enables true streaming output where each item is written immediately. // The item is encoded without pretty-printing (compact JSON on a single line). +// Uses the standard library for marshaling (to support omitzero), +// then colorizes the output if requested. func WriteNDJSONItem(w io.Writer, item any, colored bool) error { - enc := newEncoderForWriter(w, colored, false) - return enc.Encode(item) -} - -// newEncoderForWriter creates a JSON encoder for a specific writer. -// If colored is true, output will include ANSI color codes. -// If pretty is true, output will be indented. -func newEncoderForWriter(w io.Writer, colored, pretty bool) jsonEncoder { - if colored { - enc := jsoncolor.NewEncoder(w) - enc.SetColors(jsonColors()) - if pretty { - enc.SetIndent("", " ") - } - return enc - } - enc := json.NewEncoder(w) - if pretty { - enc.SetIndent("", " ") - } - return enc + return writeJSON(w, item, colored, false) } diff --git a/internal/pkg/formatter/short/utils.go b/internal/pkg/formatter/short/utils.go new file mode 100644 index 0000000..a0268d3 --- /dev/null +++ b/internal/pkg/formatter/short/utils.go @@ -0,0 +1,26 @@ +package short + +import ( + "fmt" + "strings" +) + +// FormatNumber formats an int64 with comma separators. +func FormatNumber(n int64) string { + if n < 0 { + return "-" + FormatNumber(-n) + } + if n < 1000 { + return fmt.Sprintf("%d", n) + } + + s := fmt.Sprintf("%d", n) + var result strings.Builder + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + result.WriteRune(',') + } + result.WriteRune(c) + } + return result.String() +}