diff --git a/.changeset/am-topic-users-roles-orgs.md b/.changeset/am-topic-users-roles-orgs.md new file mode 100644 index 00000000..70d23ee1 --- /dev/null +++ b/.changeset/am-topic-users-roles-orgs.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'@salesforce/b2c-tooling-sdk': patch +--- + +Account Manager (AM) topic with `users`, `roles`, and `orgs` subtopics. Use `b2c am users`, `b2c am roles`, and `b2c am orgs` for user, role, and organization management. diff --git a/docs/api-readme.md b/docs/api-readme.md index 56d0f430..237e747d 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -240,6 +240,219 @@ const { data, error } = await instance.ocapi.PATCH('/code_versions/{code_version }); ``` +## Account Manager Operations + +The SDK provides a unified client for managing users, roles, and organizations through the Account Manager API. + +### Authentication + +Account Manager operations use **OAuth implicit flow** by default, which opens a browser for interactive authentication. This is ideal for development and manual operations where you want to use roles assigned to your user account. + +For CI/CD and automation, you can also use **OAuth client credentials flow** (requires both client ID and secret). + +### Unified Client (Recommended) + +The recommended approach is to use the unified `createAccountManagerClient` which provides access to all Account Manager APIs (users, roles, and organizations): + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +// Create Account Manager client with implicit OAuth (opens browser for login) +const auth = new ImplicitOAuthStrategy({ + clientId: 'your-client-id', + // No clientSecret needed for implicit flow +}); + +const client = createAccountManagerClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Users API +const users = await client.listUsers({ size: 25, page: 0 }); +const user = await client.getUser('user-id'); +const userByLogin = await client.findUserByLogin('user@example.com'); +await client.createUser({ + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', +}); +await client.updateUser('user-id', { firstName: 'Jane' }); +await client.grantRole('user-id', 'bm-admin', 'tenant1,tenant2'); +await client.revokeRole('user-id', 'bm-admin', 'tenant1'); +await client.resetUser('user-id'); +await client.deleteUser('user-id'); + +// Roles API +const roles = await client.listRoles({ size: 20, page: 0 }); +const role = await client.getRole('bm-admin'); + +// Organizations API +const orgs = await client.listOrgs({ size: 25, page: 0 }); +const org = await client.getOrg('org-id'); +const orgByName = await client.getOrgByName('My Organization'); +const auditLogs = await client.getOrgAuditLogs('org-id'); +``` + +### Client Credentials Flow (Alternative) + +For automation and CI/CD, you can use client credentials flow: + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +// Create Account Manager client with client credentials OAuth +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Use the unified client as shown above +``` + +### Individual Clients + +If you only need access to a specific API, you can create individual clients: + +```typescript +import { + createAccountManagerUsersClient, + createAccountManagerRolesClient, + createAccountManagerOrgsClient, +} from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new ImplicitOAuthStrategy({ + clientId: 'your-client-id', +}); + +// Users client +const usersClient = createAccountManagerUsersClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Roles client +const rolesClient = createAccountManagerRolesClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Organizations client +const orgsClient = createAccountManagerOrgsClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); +``` + +### User Operations + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const client = createAccountManagerClient({}, auth); + +// List users with pagination +const users = await client.listUsers({ size: 25, page: 0 }); + +// Get user by email/login +const user = await client.findUserByLogin('user@example.com'); + +// Get user with expanded organizations and roles +const userExpanded = await client.getUser('user-id', ['organizations', 'roles']); + +// Create a new user +const newUser = await client.createUser({ + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', +}); + +// Update a user +await client.updateUser('user-id', { firstName: 'Jane' }); + +// Grant a role to a user +await client.grantRole('user-id', 'bm-admin', 'tenant1,tenant2'); // Optional tenant filter + +// Revoke a role from a user +await client.revokeRole('user-id', 'bm-admin', 'tenant1'); // Optional: remove specific scope + +// Reset user to INITIAL state +await client.resetUser('user-id'); + +// Delete (disable) a user +await client.deleteUser('user-id'); +``` + +### Role Operations + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const client = createAccountManagerClient({}, auth); + +// Get role details by ID +const role = await client.getRole('bm-admin'); + +// List all roles with pagination +const roles = await client.listRoles({ size: 25, page: 0 }); + +// List roles filtered by target type +const userRoles = await client.listRoles({ + size: 25, + page: 0, + roleTargetType: 'User', +}); +``` + +### Organization Operations + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const client = createAccountManagerClient({}, auth); + +// Get organization by ID +const org = await client.getOrg('org-123'); + +// Get organization by name +const orgByName = await client.getOrgByName('My Organization'); + +// List organizations with pagination +const orgs = await client.listOrgs({ size: 25, page: 0 }); + +// List all organizations (uses max page size of 5000) +const allOrgs = await client.listOrgs({ all: true }); + +// Get audit logs for an organization +const auditLogs = await client.getOrgAuditLogs('org-123'); +``` + +### Required Permissions + +Account Manager operations require: +- OAuth client with `sfcc.accountmanager.user.manage` scope +- Account Manager hostname configuration +- For implicit flow: roles configured on your **user account** +- For client credentials flow: roles configured on the **API client** + ## Logging Configure logging for debugging HTTP requests: diff --git a/docs/cli/account-manager.md b/docs/cli/account-manager.md new file mode 100644 index 00000000..4ede4249 --- /dev/null +++ b/docs/cli/account-manager.md @@ -0,0 +1,791 @@ +--- +description: Commands for managing Account Manager resources including users, roles, and organizations. +--- + +# Account Manager Commands + +Commands for managing Account Manager resources including users, roles, role assignments, and organizations. + +## Global Flags + +These flags are available on all Account Manager commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | + +## Authentication + +All Account Manager commands require an Account Manager API Client with OAuth authentication. + +### Required Configuration + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | +| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | + +### Required Roles + +The API client must have the following role: +- `sfcc.accountmanager.user.manage` - Required for all Account Manager operations + +### Configuration + +```bash +# Set Account Manager host +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com + +# Set OAuth credentials +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret +``` + +--- + +## User Management + +Commands for managing users in Account Manager. + +### b2c am users list + +List users in Account Manager with pagination support. + +#### Usage + +```bash +b2c am users list [FLAGS] +``` + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size` | Number of results per page (1-4000) | `20` | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +#### Default Columns + +- Email +- First Name +- Last Name +- State +- Password Expired +- 2FA Enabled +- Linked to SF +- Last Login + +#### Extended Columns + +- Roles +- Organizations + +#### Examples + +```bash +# List first page of users (default: 20 per page) +b2c am users list + +# List users with custom page size +b2c am users list --size 50 + +# Get second page of results +b2c am users list --page 1 --size 25 + +# Show all columns including roles and organizations +b2c am users list --extended + +# Show only specific columns +b2c am users list --columns mail,firstName,userState + +# Output as JSON +b2c am users list --json +``` + +#### Notes + +- Page size must be between 1 and 4000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned + +--- + +### b2c am users get + +Get detailed information about a specific user. + +#### Usage + +```bash +b2c am users get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--expand` | Comma-separated list of fields to expand. Valid values: `organizations`, `roles` | +| `--expand-all` | Expand both organizations and roles (equivalent to `--expand organizations,roles`) | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Get user details +b2c am users get user@example.com + +# Get user with expanded organizations and roles +b2c am users get user@example.com --expand-all + +# Get user with expanded organizations only +b2c am users get user@example.com --expand organizations + +# Get user with expanded organizations and roles (comma-separated) +b2c am users get user@example.com --expand organizations,roles + +# Output as JSON +b2c am users get user@example.com --json + +# Output as JSON with expanded fields +b2c am users get user@example.com --expand-all --json +``` + +#### Output + +When not using `--json`, displays formatted user information including: + +- Basic Information: ID, Email, Name, State, Organization, etc. +- Organizations: List of organization IDs (or full organization objects if expanded) +- Roles: List of role IDs (or full role objects if expanded) +- Role Tenant Filters: Role-specific tenant scope mappings + +When using `--expand` or `--expand-all`, the organizations and roles fields contain full objects instead of just IDs, providing additional details like organization names and role descriptions. + +#### Notes + +- User is identified by email address (login) +- If user is not found, an error is returned +- Use `--expand` or `--expand-all` to retrieve full organization and role objects instead of just IDs +- Invalid expand values will result in an error message listing the valid options + +--- + +### b2c am users create + +Create a new user in Account Manager. + +#### Usage + +```bash +b2c am users create --org --mail [FLAGS] +``` + +#### Required Flags + +| Flag | Description | +|------|-------------| +| `--org` | Organization ID where the user will be created | +| `--mail` | User email address (login) | + +#### Optional Flags + +| Flag | Description | +|------|-------------| +| `--first-name` | User's first name | +| `--last-name` | User's last name | +| `--display-name` | Display name | +| `--preferred-locale` | Preferred locale (e.g., `en_US`) | +| `--business-phone` | Business phone number | +| `--home-phone` | Home phone number | +| `--mobile-phone` | Mobile phone number | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Create a basic user +b2c am users create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe + +# Create a user with additional details +b2c am users create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe \ + --display-name "John Doe" \ + --preferred-locale en_US \ + --business-phone "+1-555-123-4567" + +# Output as JSON +b2c am users create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe --json +``` + +#### Notes + +- User will be created in INITIAL state +- User must be assigned roles separately using `b2c am roles grant` +- The user's primary organization is set to the specified `--org` + +--- + +### b2c am users update + +Update an existing user's information. + +#### Usage + +```bash +b2c am users update [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--first-name` | Update first name | +| `--last-name` | Update last name | +| `--display-name` | Update display name | +| `--preferred-locale` | Update preferred locale | +| `--business-phone` | Update business phone | +| `--home-phone` | Update home phone | +| `--mobile-phone` | Update mobile phone | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Update user's first name +b2c am users update user@example.com --first-name Jane + +# Update multiple fields +b2c am users update user@example.com \ + --first-name Jane \ + --last-name Smith \ + --display-name "Jane Smith" + +# Output as JSON +b2c am users update user@example.com --first-name Jane --json +``` + +#### Notes + +- At least one field must be provided to update +- Only specified fields will be updated; other fields remain unchanged +- If user is not found, an error is returned + +--- + +### b2c am users reset + +Reset a user to INITIAL state, clearing password expiration and allowing password reset. + +#### Usage + +```bash +b2c am users reset +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Examples + +```bash +# Reset user to INITIAL state +b2c am users reset user@example.com +``` + +#### Notes + +- Resets the user's state to INITIAL +- Clears password expiration timestamp +- User will need to set a new password on next login + +--- + +### b2c am users delete + +Delete (disable) a user in Account Manager. + +#### Usage + +```bash +b2c am users delete [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--purge` | Permanently delete the user (hard delete). User must be in DELETED state first. | + +#### Examples + +```bash +# Soft delete (disable) a user +b2c am users delete user@example.com + +# Permanently delete a user (must be in DELETED state first) +b2c am users delete user@example.com --purge +``` + +#### Notes + +- By default, this performs a soft delete (disables the user) +- Soft delete sets the user state to DELETED +- Use `--purge` for permanent deletion (hard delete) +- Purging requires the user to already be in DELETED state +- Deletion is permanent and cannot be undone + +--- + +## Role Management + +Commands for managing roles and role assignments in Account Manager. + +### b2c am roles list + +List roles in Account Manager with pagination support. + +#### Usage + +```bash +b2c am roles list [FLAGS] +``` + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size` | Number of results per page (1-4000) | `20` | +| `--target-type` | Filter by target type (`User` or `ApiClient`) | All types | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +#### Default Columns + +- ID +- Description +- Scope +- Internal Role + +#### Extended Columns + +- Target Type + +#### Examples + +```bash +# List first page of roles (default: 20 per page) +b2c am roles list + +# List roles with custom page size +b2c am roles list --size 50 + +# Get second page of results +b2c am roles list --page 1 --size 25 + +# Filter roles by target type +b2c am roles list --target-type User + +# Show all columns +b2c am roles list --extended + +# Show only specific columns +b2c am roles list --columns id,description + +# Output as JSON +b2c am roles list --json +``` + +#### Notes + +- Page size must be between 1 and 4000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned +- Target type filter accepts `User` or `ApiClient` + +--- + +### b2c am roles get + +Get detailed information about a specific role. + +#### Usage + +```bash +b2c am roles get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ROLE_ID` | Role identifier (e.g., `bm-admin`, `SLAS_ORGANIZATION_ADMIN`) | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Get role details +b2c am roles get bm-admin + +# Get internal role details +b2c am roles get SLAS_ORGANIZATION_ADMIN + +# Output as JSON +b2c am roles get bm-admin --json +``` + +#### Output + +When not using `--json`, displays formatted role information including: + +- Basic Information: ID, Description, Scope, Target Type +- Internal Role: Whether this is an internal role + +#### Notes + +- Role ID can be either the external role name (e.g., `bm-admin`) or internal role enum name (e.g., `ECOM_ADMIN`) +- If role is not found, an error is returned + +--- + +### b2c am roles grant + +Grant a role to a user, optionally with tenant scope. + +#### Usage + +```bash +b2c am roles grant --role [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Required Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to grant (e.g., `bm-admin`) | Yes | + +#### Optional Flags + +| Flag | Description | +|------|-------------| +| `--scope`, `-s` | Tenant scope (comma-separated list of tenant IDs). If not provided, grants role without scope restrictions. | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Grant a role without scope +b2c am roles grant user@example.com --role bm-admin + +# Grant a role with single tenant scope +b2c am roles grant user@example.com --role bm-admin --scope tenant1 + +# Grant a role with multiple tenant scopes +b2c am roles grant user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Using short flags +b2c am roles grant user@example.com -r bm-admin -s tenant1 + +# Output as JSON +b2c am roles grant user@example.com --role bm-admin --scope tenant1 --json +``` + +#### Notes + +- If the user already has the role, the scope will be updated if `--scope` is provided +- If `--scope` is not provided, the role is granted without tenant restrictions +- If `--scope` is provided, it replaces any existing scope for that role +- Multiple scopes can be specified as a comma-separated list +- If user is not found, an error is returned + +--- + +### b2c am roles revoke + +Revoke a role from a user, optionally removing specific tenant scope. + +#### Usage + +```bash +b2c am roles revoke --role [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Required Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to revoke (e.g., `bm-admin`) | Yes | + +#### Optional Flags + +| Flag | Description | +|------|-------------| +| `--scope`, `-s` | Tenant scope to remove (comma-separated). If not provided, removes the entire role. | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Revoke entire role +b2c am roles revoke user@example.com --role bm-admin + +# Revoke specific tenant scope (keeps role for other tenants) +b2c am roles revoke user@example.com --role bm-admin --scope tenant1 + +# Revoke multiple tenant scopes +b2c am roles revoke user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Using short flags +b2c am roles revoke user@example.com -r bm-admin -s tenant1 + +# Output as JSON +b2c am roles revoke user@example.com --role bm-admin --scope tenant1 --json +``` + +#### Notes + +- If `--scope` is not provided, the entire role is removed from the user +- If `--scope` is provided, only the specified tenant scopes are removed +- If all scopes are removed, the role itself is removed +- Multiple scopes can be specified as a comma-separated list +- If user is not found, an error is returned + +--- + +## Organization Management + +Commands for managing organizations in Account Manager. + +### b2c am orgs list + +List organizations in Account Manager with pagination support. + +#### Usage + +```bash +b2c am orgs list [FLAGS] +``` + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size`, `-s` | Number of results per page (1-5000) | `25` | +| `--all`, `-a` | Return all organizations (uses max page size of 5000) | `false` | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended`, `-x` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +#### Default Columns + +- ID +- Name +- Realms +- Email Domains +- 2FA Enabled +- VaaS Enabled +- SF Identity +- Min Password Length + +#### Extended Columns + +- 2FA Roles +- Verifier Types + +#### Examples + +```bash +# List first page of organizations (default: 25 per page) +b2c am orgs list + +# List organizations with custom page size +b2c am orgs list --size 50 + +# Get second page of results +b2c am orgs list --page 1 --size 25 + +# Get all organizations (uses max page size of 5000) +b2c am orgs list --all + +# Show all columns +b2c am orgs list --extended + +# Show only specific columns +b2c am orgs list --columns id,name,twoFAEnabled + +# Output as JSON +b2c am orgs list --json +``` + +#### Notes + +- Page size must be between 1 and 5000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned +- The `--all` flag uses a page size of 5000 to fetch all organizations in a single request + +--- + +### b2c am orgs get + +Get detailed information about a specific organization. + +#### Usage + +```bash +b2c am orgs get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ORG` | Organization ID or name | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Get organization details by ID +b2c am orgs get org-123 + +# Get organization details by name +b2c am orgs get "My Organization" + +# Output as JSON +b2c am orgs get org-123 --json +``` + +#### Output + +When not using `--json`, displays formatted organization information including: + +- **Organization Details**: ID, Name, 2FA Enabled, VaaS Enabled, SF Identity +- **Contact Users**: List of contact user IDs +- **Allowed Verifier Types**: List of allowed verifier types +- **Account Ids**: List of Salesforce account IDs +- **Password Policy**: Minimum Password Length, Length of Password History, Days Until Password Expires +- **Realms**: Comma-separated list of realm names +- **Email Domains**: List of allowed email domains +- **2FA Roles**: List of roles that require 2FA + +#### Notes + +- Organization can be identified by ID or name +- If organization is not found, an error is returned +- Name matching is case-sensitive and requires an exact match + +--- + +### b2c am orgs audit + +Get audit logs for an Account Manager organization. + +#### Usage + +```bash +b2c am orgs audit [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ORG` | Organization ID or name | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--columns` | Comma-separated list of columns to display | +| `--extended`, `-x` | Show all available columns | +| `--json` | Output results as JSON | + +#### Default Columns + +- Timestamp +- Author +- Email +- Event Type +- Message + +#### Examples + +```bash +# Get audit logs for an organization by ID +b2c am orgs audit org-123 + +# Get audit logs for an organization by name +b2c am orgs audit "My Organization" + +# Show all columns +b2c am orgs audit org-123 --extended + +# Show only specific columns +b2c am orgs audit org-123 --columns timestamp,eventType,eventMessage + +# Output as JSON +b2c am orgs audit org-123 --json +``` + +#### Output + +Displays a table of audit log records with the selected columns. Timestamps are formatted as `MM/DD/YYYY HH:MM:SS` for readability. + +#### Notes + +- Organization can be identified by ID or name +- If organization is not found, an error is returned +- If no audit records are found, a message is displayed +- Timestamps are displayed in a human-readable format with zero-padding for consistent spacing diff --git a/docs/cli/index.md b/docs/cli/index.md index 6e0d53d7..a191c7e7 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -43,6 +43,15 @@ These flags are available on all commands that interact with B2C instances: - [SLAS Commands](./slas) - Manage Shopper Login and Access Service (SLAS) API clients - [Custom APIs](./custom-apis) - SCAPI Custom API endpoint status +### Account Management + +- [Account Manager Commands](./account-manager) - Manage Account Manager users, roles, and organizations + +All Account Manager commands are under the `am` topic: +- `b2c am users ...` - User management commands +- `b2c am roles ...` - Role management commands +- `b2c am orgs ...` - Organization management commands + ### Utilities - [Auth Commands](./auth) - Authentication and token management diff --git a/packages/b2c-cli/README.md b/packages/b2c-cli/README.md index f89a7f55..d453aa5c 100644 --- a/packages/b2c-cli/README.md +++ b/packages/b2c-cli/README.md @@ -186,6 +186,75 @@ List and inspect storefront sites. b2c sites list ``` +### User Management (Account Manager) + +Manage users in Account Manager. + +```sh +# List users with pagination +b2c am users list --page 0 --size 20 + +# Get user details by email +b2c am users get user@example.com + +# Create a new user +b2c am users create --org org-id --mail user@example.com --first-name John --last-name Doe + +# Update a user +b2c am users update user@example.com --first-name Jane + +# Reset a user to INITIAL state +b2c am users reset user@example.com + +# Delete (disable) a user +b2c am users delete user@example.com +``` + +### Role Management (Account Manager) + +Manage roles and role assignments in Account Manager. + +```sh +# List roles with pagination +b2c am roles list --page 0 --size 20 --target-type User + +# Get role details +b2c am roles get bm-admin + +# Grant a role to a user +b2c am roles grant user@example.com --role bm-admin + +# Grant a role with tenant scope +b2c am roles grant user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Revoke a role from a user +b2c am roles revoke user@example.com --role bm-admin +``` + +### Organization Management (Account Manager) + +Manage organizations in Account Manager. + +```sh +# List organizations with pagination +b2c am orgs list --page 0 --size 25 + +# List all organizations +b2c am orgs list --all + +# Get organization details by ID +b2c am orgs get org-123 + +# Get organization details by name +b2c am orgs get "My Organization" + +# Get audit logs for an organization +b2c am orgs audit org-123 + +# Get audit logs with extended columns +b2c am orgs audit org-123 --extended +``` + ### Authentication Get OAuth tokens for scripting. diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index ef1338e3..479f9e13 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -42,6 +42,7 @@ "eslint-plugin-prettier": "^5.5.4", "execa": "^9.6.1", "mocha": "^10", + "msw": "^2.0.0", "oclif": "^4", "prettier": "^3.6.2", "shx": "^0.3.3", @@ -140,6 +141,20 @@ "sites": { "description": "List and inspect storefront sites\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/sites.html" }, + "am": { + "description": "Manage Account Manager resources", + "subtopics": { + "users": { + "description": "Manage Account Manager users" + }, + "roles": { + "description": "Manage roles for Account Manager users" + }, + "orgs": { + "description": "Manage Account Manager organizations" + } + } + }, "slas": { "description": "Manage SLAS API clients and credentials\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/slas.html", "subtopics": { diff --git a/packages/b2c-cli/src/commands/am/orgs/audit.ts b/packages/b2c-cli/src/commands/am/orgs/audit.ts new file mode 100644 index 00000000..e8536396 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/orgs/audit.ts @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AuditLogRecord, AuditLogCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${month}/${day}/${year} ${hours}:${minutes}:${seconds}`; +} + +const COLUMNS: Record> = { + timestamp: { + header: 'Timestamp', + get: (r) => (r.timestamp ? formatTimestamp(r.timestamp) : '-'), + }, + authorDisplayName: { + header: 'Author', + get: (r) => r.authorDisplayName || '-', + }, + authorEmail: { + header: 'Email', + get: (r) => r.authorEmail || '-', + }, + eventType: { + header: 'Event Type', + get: (r) => r.eventType || '-', + }, + eventMessage: { + header: 'Message', + get: (r) => r.eventMessage || '-', + }, +}; + +const DEFAULT_COLUMNS = ['timestamp', 'authorDisplayName', 'authorEmail', 'eventType', 'eventMessage']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to get audit logs for an Account Manager organization. + */ +export default class OrgAudit extends AmCommand { + static args = { + org: Args.string({ + description: 'Organization ID or name', + required: true, + }), + }; + + static description = t('commands.org.audit.description', 'Get audit logs for an Account Manager organization'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> org-id', + '<%= config.bin %> <%= command.id %> "My Organization"', + '<%= config.bin %> <%= command.id %> org-id --json', + ]; + + static flags = { + columns: Flags.string({ + description: 'Comma-separated list of columns to display', + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show extended columns', + }), + }; + + async run(): Promise { + const {org} = this.args; + + this.log(t('commands.org.audit.fetching', 'Fetching organization {{org}}...', {org})); + + // Get organization first (by ID or name) + let organization; + try { + organization = await this.accountManagerClient.getOrg(org); + } catch (error) { + // If not found by ID, try by name + if (error instanceof Error && error.message.includes('not found')) { + try { + organization = await this.accountManagerClient.getOrgByName(org); + } catch { + throw new Error(t('commands.org.audit.orgNotFound', 'Organization {{org}} not found', {org})); + } + } else { + throw error; + } + } + + this.log(t('commands.org.audit.fetchingLogs', 'Fetching audit logs...')); + + const result = await this.accountManagerClient.getOrgAuditLogs(organization.id); + + if (this.jsonEnabled()) { + return result; + } + + if (!result.content || result.content.length === 0) { + this.log(t('commands.org.audit.noResults', 'No audit records found')); + return result; + } + + // Determine columns to display + let columnsToShow = DEFAULT_COLUMNS; + if (this.flags.columns) { + columnsToShow = this.flags.columns.split(',').map((c) => c.trim()); + } else if (this.flags.extended) { + columnsToShow = Object.keys(COLUMNS); + } + + tableRenderer.render(result.content, columnsToShow); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/am/orgs/get.ts b/packages/b2c-cli/src/commands/am/orgs/get.ts new file mode 100644 index 00000000..11a0da37 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/orgs/get.ts @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerOrganization} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to get details of a single Account Manager organization. + */ +export default class OrgGet extends AmCommand { + static args = { + org: Args.string({ + description: 'Organization ID or name', + required: true, + }), + }; + + static description = t('commands.org.get.description', 'Get details of an Account Manager organization'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> org-id', + '<%= config.bin %> <%= command.id %> "My Organization"', + '<%= config.bin %> <%= command.id %> org-id --json', + ]; + + async run(): Promise { + const {org} = this.args; + + this.log(t('commands.org.get.fetching', 'Fetching organization {{org}}...', {org})); + + // Try to get by ID first, then by name + let organization: AccountManagerOrganization; + try { + organization = await this.accountManagerClient.getOrg(org); + } catch (error) { + // If not found by ID, try by name + if (error instanceof Error && error.message.includes('not found')) { + try { + organization = await this.accountManagerClient.getOrgByName(org); + } catch (nameError) { + // Preserve the specific error message if it's already a "not found" error + if (nameError instanceof Error && nameError.message.includes('not found')) { + throw nameError; + } + throw new Error(t('commands.org.get.notFound', 'Organization {{org}} not found', {org})); + } + } else { + throw error; + } + } + + if (this.jsonEnabled()) { + return organization; + } + + this.printOrgDetails(organization); + + return organization; + } + + private printAccountIds(ui: ReturnType, org: AccountManagerOrganization): void { + const sfAccountIds = org.sfAccountIds as string[] | undefined; + if (sfAccountIds && sfAccountIds.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Account Ids', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const accountId of sfAccountIds) { + ui.div({text: ` - ${accountId}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printAllowedVerifierTypes(ui: ReturnType, org: AccountManagerOrganization): void { + if (org.allowedVerifierTypes && org.allowedVerifierTypes.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Allowed Verifier Types', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const verifierType of org.allowedVerifierTypes) { + ui.div({text: ` - ${verifierType}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printBasicFields(ui: ReturnType, org: AccountManagerOrganization): void { + ui.div({text: 'Organization Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['ID', org.id], + ['Name', org.name], + ['2FA Enabled', org.twoFAEnabled ? 'Yes' : 'No'], + ['VaaS Enabled', org.vaasEnabled ? 'Yes' : 'No'], + ['SF Identity', org.sfIdentityFederation ? 'Yes' : 'No'], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 20, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + } + + private printContactUsers(ui: ReturnType, org: AccountManagerOrganization): void { + const contactUsers = org.contactUsers as string[] | undefined; + if (contactUsers && contactUsers.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Contact Users', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const userId of contactUsers) { + ui.div({text: ` - ${userId}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printEmailDomains(ui: ReturnType, org: AccountManagerOrganization): void { + const emailsDomains = org.emailsDomains as string[] | undefined; + if (emailsDomains && emailsDomains.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Email Domains', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const domain of emailsDomains) { + ui.div({text: ` - ${domain}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printOrgDetails(org: AccountManagerOrganization): void { + const ui = cliui({width: process.stdout.columns || 80}); + + this.printBasicFields(ui, org); + this.printContactUsers(ui, org); + this.printAllowedVerifierTypes(ui, org); + this.printAccountIds(ui, org); + this.printRealms(ui, org); + this.printEmailDomains(ui, org); + this.printTwoFARoles(ui, org); + this.printPasswordPolicy(ui, org); + + ux.stdout(ui.toString()); + } + + private printPasswordPolicy(ui: ReturnType, org: AccountManagerOrganization): void { + const passwordMinEntropy = org.passwordMinEntropy as number | undefined; + const passwordHistorySize = org.passwordHistorySize as number | undefined; + const passwordDaysExpiration = org.passwordDaysExpiration as number | undefined; + + // Only show section if at least one password policy attribute exists + if (passwordMinEntropy === undefined && passwordHistorySize === undefined && passwordDaysExpiration === undefined) { + return; + } + + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Password Policy', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['Minimum Password Length', passwordMinEntropy === undefined ? undefined : passwordMinEntropy.toString()], + ['Length of Password History', passwordHistorySize === undefined ? undefined : passwordHistorySize.toString()], + [ + 'Days Until Password Expires', + passwordDaysExpiration === undefined ? undefined : passwordDaysExpiration.toString(), + ], + ]; + + for (const [label, value] of fields) { + if (value === undefined) { + continue; + } + ui.div({text: `${label}:`, width: 30, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + + private printRealms(ui: ReturnType, org: AccountManagerOrganization): void { + if (org.realms && org.realms.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Realms', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + ui.div({text: org.realms.join(', '), padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + + private printTwoFARoles(ui: ReturnType, org: AccountManagerOrganization): void { + if (org.twoFARoles && org.twoFARoles.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: '2FA Roles', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const role of org.twoFARoles) { + ui.div({text: ` - ${role}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } +} diff --git a/packages/b2c-cli/src/commands/am/orgs/list.ts b/packages/b2c-cli/src/commands/am/orgs/list.ts new file mode 100644 index 00000000..76869311 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/orgs/list.ts @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, Errors} from '@oclif/core'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerOrganization, OrganizationCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (o) => o.id || '-', + }, + name: { + header: 'Name', + get: (o) => o.name || '-', + }, + realms: { + header: 'Realms', + get: (o) => (o.realms && o.realms.length > 0 ? o.realms.length.toString() : '0'), + }, + emailsDomains: { + header: 'Email Domains', + get(o) { + const domains = o.emailsDomains as string[] | undefined; + return domains && domains.length > 0 ? domains.length.toString() : '0'; + }, + }, + twoFARoles: { + header: '2FA Roles', + get: (o) => (o.twoFARoles && o.twoFARoles.length > 0 ? o.twoFARoles.length.toString() : '0'), + }, + twoFAEnabled: { + header: '2FA Enabled', + get: (o) => (o.twoFAEnabled ? 'Yes' : 'No'), + }, + allowedVerifierTypes: { + header: 'Verifier Types', + get: (o) => + o.allowedVerifierTypes && o.allowedVerifierTypes.length > 0 ? o.allowedVerifierTypes.length.toString() : '0', + }, + vaasEnabled: { + header: 'VaaS Enabled', + get: (o) => (o.vaasEnabled ? 'Yes' : 'No'), + }, + sfIdentityFederation: { + header: 'SF Identity', + get: (o) => (o.sfIdentityFederation ? 'Yes' : 'No'), + }, + passwordMinEntropy: { + header: 'Min Password Length', + get(o) { + const minEntropy = o.passwordMinEntropy as number | undefined; + return minEntropy === undefined ? '-' : minEntropy.toString(); + }, + }, +}; + +const DEFAULT_COLUMNS = [ + 'id', + 'name', + 'realms', + 'emailsDomains', + 'twoFAEnabled', + 'vaasEnabled', + 'sfIdentityFederation', + 'passwordMinEntropy', +]; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list Account Manager organizations. + */ +export default class OrgList extends AmCommand { + static description = t('commands.org.list.description', 'List Account Manager organizations'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --size 50', + '<%= config.bin %> <%= command.id %> --all', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + size: Flags.integer({ + char: 's', + description: 'Page size (default: 25, min: 1, max: 5000)', + }), + page: Flags.integer({ + description: 'Page number (zero-based index, default: 0, min: 0)', + }), + all: Flags.boolean({ + char: 'a', + description: 'Return all organizations (uses max page size of 5000)', + }), + columns: Flags.string({ + description: 'Comma-separated list of columns to display', + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show extended columns', + }), + }; + + async run(): Promise { + const {size = 25, page = 0, all = false} = this.flags; + + // Validate size + if (size !== undefined) { + if (size < 1) { + throw new Errors.CLIError(t('commands.org.list.errors.sizeMin', 'Size must be at least 1')); + } + if (size > 5000) { + throw new Errors.CLIError(t('commands.org.list.errors.sizeMax', 'Size cannot exceed 5000')); + } + } + + // Validate page + if (page !== undefined && page < 0) { + throw new Errors.CLIError(t('commands.org.list.errors.pageMin', 'Page must be a non-negative integer')); + } + + this.log(t('commands.org.list.fetching', 'Fetching organizations...')); + + const result = await this.accountManagerClient.listOrgs({ + size, + page, + all, + }); + + if (this.jsonEnabled()) { + return result; + } + + if (!result.content || result.content.length === 0) { + this.log(t('commands.org.list.noResults', 'No organizations found')); + return result; + } + + // Determine columns to display + let columnsToShow = DEFAULT_COLUMNS; + if (this.flags.columns) { + columnsToShow = this.flags.columns.split(',').map((c) => c.trim()); + } else if (this.flags.extended) { + columnsToShow = Object.keys(COLUMNS); + } + + tableRenderer.render(result.content, columnsToShow); + + // Show pagination hint if more pages available + const totalPages = result.totalPages ?? 0; + const currentPage = result.number ?? 0; + if (totalPages > 1 && currentPage < totalPages - 1) { + this.log( + t('commands.org.list.morePages', 'More organizations available. Use --page {{page}} to view the next page.', { + page: currentPage + 1, + }), + ); + } + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/am/roles/get.ts b/packages/b2c-cli/src/commands/am/roles/get.ts new file mode 100644 index 00000000..22111265 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/roles/get.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerRole} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to get details of a single Account Manager role. + */ +export default class RoleGet extends AmCommand { + static args = { + roleId: Args.string({ + description: 'Role ID', + required: true, + }), + }; + + static description = t('commands.role.get.description', 'Get details of an Account Manager role'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> bm-admin', + '<%= config.bin %> <%= command.id %> bm-user --json', + ]; + + async run(): Promise { + const {roleId} = this.args; + + this.log(t('commands.role.get.fetching', 'Fetching role {{roleId}}...', {roleId})); + + const role = await this.accountManagerClient.getRole(roleId); + + if (this.jsonEnabled()) { + return role; + } + + this.printRoleDetails(role); + + return role; + } + + private printRoleDetails(role: AccountManagerRole): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Role Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['ID', role.id], + ['Description', role.description], + ['Role Enum Name', role.roleEnumName], + ['Scope', role.scope], + ['Target Type', role.targetType || undefined], + ['2FA Enabled', role.twoFAEnabled?.toString()], + ['Internal Role', (role as {internalRole?: boolean}).internalRole?.toString()], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + + // Permissions + if (role.permissions && role.permissions.length > 0) { + ui.div({text: 'Permissions', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + ui.div( + {text: 'Permissions:', width: 25, padding: [0, 2, 0, 0]}, + {text: role.permissions.join(', '), padding: [0, 0, 0, 0]}, + ); + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/am/roles/grant.ts b/packages/b2c-cli/src/commands/am/roles/grant.ts new file mode 100644 index 00000000..ee5d7efd --- /dev/null +++ b/packages/b2c-cli/src/commands/am/roles/grant.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to grant a role to an Account Manager user. + */ +export default class RoleGrant extends AmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.role.grant.description', 'Grant a role to an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin', + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin --scope tenant1,tenant2', + ]; + + static flags = { + role: Flags.string({ + char: 'r', + description: 'Role to grant', + required: true, + }), + scope: Flags.string({ + char: 's', + description: 'Scope for the role (tenant IDs, comma-separated)', + }), + }; + + async run(): Promise { + const {login} = this.args; + const {role, scope} = this.flags; + + this.log(t('commands.role.grant.fetching', 'Fetching user {{login}}...', {login})); + + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.role.grant.notFound', 'User {{login}} not found', {login})); + } + + if (!user.id) { + this.error(t('commands.role.grant.noId', 'User does not have an ID')); + } + + this.log( + t('commands.role.grant.granting', 'Granting role {{role}} to user {{login}}...', { + role, + login, + }), + ); + + const updatedUser = await this.accountManagerClient.grantRole(user.id, role, scope); + + if (this.jsonEnabled()) { + return updatedUser; + } + + const message = scope + ? t('commands.role.grant.successWithScope', 'User {{login}} granted role {{role}} with scope {{scope}}.', { + login, + role, + scope, + }) + : t('commands.role.grant.success', 'User {{login}} granted role {{role}}.', {login, role}); + + this.log(message); + + return updatedUser; + } +} diff --git a/packages/b2c-cli/src/commands/am/roles/list.ts b/packages/b2c-cli/src/commands/am/roles/list.ts new file mode 100644 index 00000000..3ce6d828 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/roles/list.ts @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, Errors} from '@oclif/core'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerRole, RoleCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (r) => r.id || '-', + }, + description: { + header: 'Description', + get: (r) => r.description || '-', + }, + roleEnumName: { + header: 'Role Enum Name', + get: (r) => r.roleEnumName || '-', + }, + scope: { + header: 'Scope', + get: (r) => r.scope || '-', + }, + targetType: { + header: 'Target Type', + get: (r) => r.targetType || '-', + }, + twoFAEnabled: { + header: '2FA Enabled', + get: (r) => (r.twoFAEnabled ? 'Yes' : 'No'), + }, + permissions: { + header: 'Permissions', + get(r) { + if (!r.permissions || r.permissions.length === 0) return '-'; + return r.permissions.join(', '); + }, + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['id', 'description', 'roleEnumName']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list Account Manager roles. + */ +export default class RoleList extends AmCommand { + static description = t('commands.role.list.description', 'List Account Manager roles'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --size 50', + '<%= config.bin %> <%= command.id %> --target-type User', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + size: Flags.integer({ + char: 's', + description: 'Page size (default: 20, min: 1, max: 4000)', + }), + page: Flags.integer({ + description: 'Page number (zero-based index, default: 0, min: 0)', + }), + 'target-type': Flags.string({ + char: 't', + description: 'Filter by target type (User or ApiClient)', + options: ['User', 'ApiClient'], + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns including extended fields', + default: false, + }), + }; + + async run(): Promise { + const {size, page, 'target-type': targetType} = this.flags; + + // Ensure size and page are numbers (oclif might return undefined or string) + const pageSize = size === undefined ? 20 : Number(size); + const pageNumber = page === undefined ? 0 : Number(page); + + // Validate size parameter + if (pageSize < 1 || pageSize > 4000) { + throw new Errors.CLIError( + t('commands.role.list.invalidSize', 'Page size must be between 1 and 4000 (inclusive). Received: {{size}}', { + size: pageSize, + }), + ); + } + + // Validate page parameter (must be zero-based index, non-negative) + if (pageNumber < 0 || !Number.isFinite(pageNumber)) { + throw new Errors.CLIError( + t( + 'commands.role.list.invalidPage', + 'Page number must be a non-negative integer (zero-based index). Received: {{page}}', + {page: pageNumber}, + ), + ); + } + + this.log(t('commands.role.list.fetching', 'Fetching roles...')); + + const result = await this.accountManagerClient.listRoles({ + size: pageSize, + page: pageNumber, + roleTargetType: targetType as 'ApiClient' | 'User' | undefined, + }); + + if (this.jsonEnabled()) { + return result; + } + + const roles = result.content || []; + if (roles.length === 0) { + this.log(t('commands.role.list.noRoles', 'No roles found.')); + return result; + } + + tableRenderer.render(roles, this.getSelectedColumns()); + + // Check if there are more pages (if we got a full page of results, there might be more) + if (roles.length === pageSize) { + const nextPage = pageNumber + 1; + this.log( + t('commands.role.list.moreRoles', 'More roles available. Use --page {{nextPage}} to view the next page.', { + nextPage, + }), + ); + } + + return result; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + return tableRenderer.getColumnKeys(); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/am/roles/revoke.ts b/packages/b2c-cli/src/commands/am/roles/revoke.ts new file mode 100644 index 00000000..ce30100c --- /dev/null +++ b/packages/b2c-cli/src/commands/am/roles/revoke.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to revoke a role from an Account Manager user. + */ +export default class RoleRevoke extends AmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.role.revoke.description', 'Revoke a role from an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin', + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin --scope tenant1', + ]; + + static flags = { + role: Flags.string({ + char: 'r', + description: 'Role to revoke', + required: true, + }), + scope: Flags.string({ + char: 's', + description: 'Scope to remove (if not provided, removes entire role)', + }), + }; + + async run(): Promise { + const {login} = this.args; + const {role, scope} = this.flags; + + this.log(t('commands.role.revoke.fetching', 'Fetching user {{login}}...', {login})); + + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.role.revoke.notFound', 'User {{login}} not found', {login})); + } + + if (!user.id) { + this.error(t('commands.role.revoke.noId', 'User does not have an ID')); + } + + this.log( + t('commands.role.revoke.revoking', 'Revoking role {{role}} from user {{login}}...', { + role, + login, + }), + ); + + const updatedUser = await this.accountManagerClient.revokeRole(user.id, role, scope); + + if (this.jsonEnabled()) { + return updatedUser; + } + + const message = scope + ? t('commands.role.revoke.successWithScope', 'User {{login}} revoked role {{role}} with scope {{scope}}.', { + login, + role, + scope, + }) + : t('commands.role.revoke.success', 'User {{login}} revoked role {{role}}.', {login, role}); + + this.log(message); + + return updatedUser; + } +} diff --git a/packages/b2c-cli/src/commands/am/users/create.ts b/packages/b2c-cli/src/commands/am/users/create.ts new file mode 100644 index 00000000..61db5c85 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/users/create.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to create a new Account Manager user. + */ +export default class UserCreate extends AmCommand { + static description = t('commands.user.create.description', 'Create a new Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --org org-id --mail user@example.com --first-name John --last-name Doe', + '<%= config.bin %> <%= command.id %> --org org-id --mail user@example.com --first-name John --last-name Doe --json', + ]; + + static flags = { + org: Flags.string({ + char: 'o', + description: 'Organization ID to create the user in', + required: true, + }), + mail: Flags.string({ + char: 'm', + description: 'User email (login)', + required: true, + }), + 'first-name': Flags.string({ + description: 'First name', + required: true, + }), + 'last-name': Flags.string({ + description: 'Last name', + required: true, + }), + }; + + async run(): Promise { + const {org, mail, 'first-name': firstName, 'last-name': lastName} = this.flags; + + this.log(t('commands.user.create.creating', 'Creating user {{mail}} in organization {{org}}...', {mail, org})); + + const user = await this.accountManagerClient.createUser({ + mail, + firstName, + lastName, + organizations: [org], + primaryOrganization: org, + }); + + if (this.jsonEnabled()) { + return user; + } + + this.log( + t('commands.user.create.success', 'User {{mail}} created successfully in organization {{org}}.', {mail, org}), + ); + + return user; + } +} diff --git a/packages/b2c-cli/src/commands/am/users/delete.ts b/packages/b2c-cli/src/commands/am/users/delete.ts new file mode 100644 index 00000000..7847510e --- /dev/null +++ b/packages/b2c-cli/src/commands/am/users/delete.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to delete an Account Manager user. + */ +export default class UserDelete extends AmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.delete.description', 'Delete an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --purge', + ]; + + static flags = { + purge: Flags.boolean({ + description: 'Purge the user completely (hard delete). User must be in DELETED state first.', + default: false, + }), + }; + + async run(): Promise { + const {login} = this.args; + const {purge} = this.flags; + + this.log(t('commands.user.delete.fetching', 'Fetching user {{login}}...', {login})); + + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.user.delete.notFound', 'User {{login}} not found', {login})); + } + + if (!user.id) { + this.error(t('commands.user.delete.noId', 'User does not have an ID')); + } + + if (purge) { + this.log( + t('commands.user.delete.purging', 'Purging user {{login}}...', { + login, + }), + ); + await this.accountManagerClient.purgeUser(user.id); + } else { + this.log( + t('commands.user.delete.deleting', 'Deleting user {{login}}...', { + login, + }), + ); + await this.accountManagerClient.deleteUser(user.id); + } + + if (this.jsonEnabled()) { + return; + } + + this.log( + t('commands.user.delete.success', 'User {{login}} {{action}} successfully.', { + login, + action: purge ? 'purged' : 'deleted', + }), + ); + } +} diff --git a/packages/b2c-cli/src/commands/am/users/get.ts b/packages/b2c-cli/src/commands/am/users/get.ts new file mode 100644 index 00000000..19db7d53 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/users/get.ts @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser, UserExpandOption} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Valid expand values for the users API. + * Extracted from the generated API types to ensure consistency. + */ +const VALID_EXPAND_VALUES: readonly UserExpandOption[] = ['organizations', 'roles'] as const; + +/** + * Command to get details of a single Account Manager user. + */ +export default class UserGet extends AmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.get.description', 'Get details of an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --json', + '<%= config.bin %> <%= command.id %> user@example.com --expand-all', + '<%= config.bin %> <%= command.id %> user@example.com --expand organizations', + '<%= config.bin %> <%= command.id %> user@example.com --expand organizations,roles', + ]; + + static flags = { + expand: Flags.string({ + description: t( + 'flags.expand.description', + 'Comma-separated list of fields to expand. Valid values: organizations, roles', + ), + multiple: false, + }), + 'expand-all': Flags.boolean({ + description: t('flags.expandAll.description', 'Expand both organizations and roles'), + default: false, + }), + }; + + async run(): Promise { + const {login} = this.args; + const {expand: expandRaw, 'expand-all': expandAll} = this.flags; + + // Validate and process expand flags + let expand: undefined | UserExpandOption[]; + + if (expandAll) { + // --expand-all expands both + expand = ['organizations', 'roles']; + } else if (expandRaw) { + // Parse comma-separated values + const expandValues = expandRaw + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + + // Validate each value + const invalidValues: string[] = []; + const validValues: UserExpandOption[] = []; + + for (const value of expandValues) { + if (VALID_EXPAND_VALUES.includes(value as UserExpandOption)) { + validValues.push(value as UserExpandOption); + } else { + invalidValues.push(value); + } + } + + if (invalidValues.length > 0) { + this.error( + t('commands.user.get.invalidExpand', 'Invalid expand value(s): {{invalid}}. Valid values are: {{valid}}', { + invalid: invalidValues.join(', '), + valid: VALID_EXPAND_VALUES.join(', '), + }), + ); + } + + if (validValues.length > 0) { + expand = validValues; + } + } + + this.log(t('commands.user.get.fetching', 'Fetching user {{login}}...', {login})); + + const user = await this.accountManagerClient.findUserByLogin(login, expand); + + if (!user) { + this.error(t('commands.user.get.notFound', 'User {{login}} not found', {login})); + } + + if (this.jsonEnabled()) { + return user; + } + + this.printUserDetails(user); + + return user; + } + + private printBasicFields(ui: ReturnType, user: AccountManagerUser): void { + const isPasswordExpired = user.passwordExpirationTimestamp + ? user.passwordExpirationTimestamp < Date.now() + : undefined; + const twoFAEnabled = user.verifiers && user.verifiers.length > 0 ? 'Yes' : 'No'; + + const fields: [string, string | undefined][] = [ + ['ID', user.id], + ['Email', user.mail], + ['First Name', user.firstName], + ['Last Name', user.lastName], + ['Display Name', user.displayName], + ['State', user.userState], + ['Primary Organization', user.primaryOrganization], + ['Preferred Locale', user.preferredLocale || undefined], + ['Business Phone', user.businessPhone || undefined], + ['Home Phone', user.homePhone || undefined], + ['Mobile Phone', user.mobilePhone || undefined], + ['Linked to SF Identity', user.linkedToSfIdentity?.toString()], + ['2FA Enabled', twoFAEnabled], + ['Password Expired', isPasswordExpired === undefined ? undefined : isPasswordExpired ? 'Yes' : 'No'], + ['Last Login', user.lastLoginDate || undefined], + ['Created At', user.createdAt ? new Date(user.createdAt).toLocaleString() : undefined], + ['Last Modified', user.lastModified ? new Date(user.lastModified).toLocaleString() : undefined], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + } + + private printOrganizations(ui: ReturnType, user: AccountManagerUser): void { + if (user.organizations === undefined || user.organizations.length === 0) { + return; + } + + ui.div({text: 'Organizations', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const orgIds = user.organizations.map((o) => (typeof o === 'string' ? o : o.id || 'Unknown')); + ui.div( + {text: 'Organization IDs:', width: 25, padding: [0, 2, 0, 0]}, + {text: orgIds.join(', '), padding: [0, 0, 0, 0]}, + ); + } + + private printRoles(ui: ReturnType, user: AccountManagerUser): void { + if (user.roles === undefined || user.roles.length === 0) { + return; + } + + ui.div({text: 'Roles', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const roleNames = user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || r.id || 'Unknown')); + ui.div({text: 'Role IDs:', width: 25, padding: [0, 2, 0, 0]}, {text: roleNames.join(', '), padding: [0, 0, 0, 0]}); + } + + private printRoleTenantFilters(ui: ReturnType, user: AccountManagerUser): void { + if (user.roleTenantFilterMap === undefined || Object.keys(user.roleTenantFilterMap).length === 0) { + return; + } + + ui.div({text: 'Role Tenant Filters', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const [roleId, filter] of Object.entries(user.roleTenantFilterMap)) { + const filterValue = typeof filter === 'string' ? filter : JSON.stringify(filter); + ui.div({text: `${roleId}:`, width: 30, padding: [0, 2, 0, 0]}, {text: filterValue, padding: [0, 0, 0, 0]}); + } + } + + private printUserDetails(user: AccountManagerUser): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'User Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + this.printBasicFields(ui, user); + this.printOrganizations(ui, user); + this.printRoles(ui, user); + this.printRoleTenantFilters(ui, user); + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/am/users/list.ts b/packages/b2c-cli/src/commands/am/users/list.ts new file mode 100644 index 00000000..d42722cb --- /dev/null +++ b/packages/b2c-cli/src/commands/am/users/list.ts @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, Errors} from '@oclif/core'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser, UserCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + mail: { + header: 'Email', + get: (u) => u.mail || '-', + }, + firstName: { + header: 'First Name', + get: (u) => u.firstName || '-', + }, + lastName: { + header: 'Last Name', + get: (u) => u.lastName || '-', + }, + userState: { + header: 'State', + get: (u) => u.userState || '-', + }, + passwordExpired: { + header: 'Password Expired', + get: (u) => (u.passwordExpirationTimestamp ? 'Yes' : 'No'), + }, + twoFAEnabled: { + header: '2FA Enabled', + get: (u) => (u.verifiers && u.verifiers.length > 0 ? 'Yes' : 'No'), + }, + linkedToSfIdentity: { + header: 'Linked to SF', + get: (u) => (u.linkedToSfIdentity ? 'Yes' : 'No'), + }, + lastLoginDate: { + header: 'Last Login', + get: (u) => u.lastLoginDate || '-', + }, + roles: { + header: 'Roles', + get(u) { + if (!u.roles || u.roles.length === 0) return '-'; + return u.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')).join(', '); + }, + extended: true, + }, + organizations: { + header: 'Organizations', + get(u) { + if (!u.organizations || u.organizations.length === 0) return '-'; + return u.organizations.map((o) => (typeof o === 'string' ? o : o.id || '')).join(', '); + }, + extended: true, + }, +}; + +const DEFAULT_COLUMNS = [ + 'mail', + 'firstName', + 'lastName', + 'userState', + 'passwordExpired', + 'twoFAEnabled', + 'linkedToSfIdentity', + 'lastLoginDate', +]; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list Account Manager users. + */ +export default class UserList extends AmCommand { + static description = t('commands.user.list.description', 'List Account Manager users'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --size 50', + '<%= config.bin %> <%= command.id %> --size 100', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + size: Flags.integer({ + char: 's', + description: 'Page size (default: 20, min: 1, max: 4000)', + }), + page: Flags.integer({ + description: 'Page number (zero-based index, default: 0, min: 0)', + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns including extended fields', + default: false, + }), + }; + + async run(): Promise { + const {size, page} = this.flags; + + // Ensure size and page are numbers (oclif might return undefined or string) + const pageSize = size === undefined ? 20 : Number(size); + const pageNumber = page === undefined ? 0 : Number(page); + + // Validate size parameter + if (pageSize < 1 || pageSize > 4000) { + throw new Errors.CLIError( + t('commands.user.list.invalidSize', 'Page size must be between 1 and 4000 (inclusive). Received: {{size}}', { + size: pageSize, + }), + ); + } + + // Validate page parameter (must be zero-based index, non-negative) + if (pageNumber < 0 || !Number.isFinite(pageNumber)) { + throw new Errors.CLIError( + t( + 'commands.user.list.invalidPage', + 'Page number must be a non-negative integer (zero-based index). Received: {{page}}', + {page: pageNumber}, + ), + ); + } + + this.log(t('commands.user.list.fetching', 'Fetching users...')); + + const result = await this.accountManagerClient.listUsers({ + size: pageSize, + page: pageNumber, + }); + + if (this.jsonEnabled()) { + return result; + } + + const users = result.content || []; + if (users.length === 0) { + this.log(t('commands.user.list.noUsers', 'No users found.')); + return result; + } + + tableRenderer.render(users, this.getSelectedColumns()); + + // Check if there are more pages (if we got a full page of results, there might be more) + if (users.length === pageSize) { + const nextPage = pageNumber + 1; + this.log( + t('commands.user.list.moreUsers', 'More users available. Use --page {{nextPage}} to view the next page.', { + nextPage, + }), + ); + } + + return result; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + return tableRenderer.getColumnKeys(); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/am/users/reset.ts b/packages/b2c-cli/src/commands/am/users/reset.ts new file mode 100644 index 00000000..88f36522 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/users/reset.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to reset an Account Manager user password. + */ +export default class UserReset extends AmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.reset.description', 'Reset an Account Manager user password'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --json', + ]; + + async run(): Promise { + const {login} = this.args; + + this.log(t('commands.user.reset.fetching', 'Fetching user {{login}}...', {login})); + + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.user.reset.notFound', 'User {{login}} not found', {login})); + } + + if (!user.id) { + this.error(t('commands.user.reset.noId', 'User does not have an ID')); + } + + this.log(t('commands.user.reset.resetting', 'Resetting password for user {{login}}...', {login})); + + await this.accountManagerClient.resetUser(user.id); + + if (this.jsonEnabled()) { + return; + } + + this.log(t('commands.user.reset.success', 'Password reset for user {{login}} completed successfully.', {login})); + } +} diff --git a/packages/b2c-cli/src/commands/am/users/update.ts b/packages/b2c-cli/src/commands/am/users/update.ts new file mode 100644 index 00000000..d6203cb4 --- /dev/null +++ b/packages/b2c-cli/src/commands/am/users/update.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser, UserUpdate as UserUpdateType} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Command to update an Account Manager user. + */ +export default class UserUpdate extends AmCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.update.description', 'Update an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --first-name Jane', + '<%= config.bin %> <%= command.id %> user@example.com --last-name Smith --json', + ]; + + static flags = { + 'first-name': Flags.string({ + description: 'First name', + }), + 'last-name': Flags.string({ + description: 'Last name', + }), + }; + + async run(): Promise { + const {login} = this.args; + const {'first-name': firstName, 'last-name': lastName} = this.flags; + + this.log(t('commands.user.update.fetching', 'Fetching user {{login}}...', {login})); + + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.user.update.notFound', 'User {{login}} not found', {login})); + } + + if (!user.id) { + this.error(t('commands.user.update.noId', 'User does not have an ID')); + } + + const changes: Partial = {}; + if (firstName !== undefined) { + changes.firstName = firstName; + } + if (lastName !== undefined) { + changes.lastName = lastName; + } + + if (Object.keys(changes).length === 0) { + this.error(t('commands.user.update.noChanges', 'No changes specified. Provide at least one field to update.')); + } + + this.log(t('commands.user.update.updating', 'Updating user {{login}}...', {login})); + + const updatedUser = await this.accountManagerClient.updateUser(user.id, changes as UserUpdateType); + + if (this.jsonEnabled()) { + return updatedUser; + } + + this.log(t('commands.user.update.success', 'User {{login}} updated successfully.', {login})); + + return updatedUser; + } +} diff --git a/packages/b2c-cli/test/commands/am/orgs/audit.test.ts b/packages/b2c-cli/test/commands/am/orgs/audit.test.ts new file mode 100644 index 00000000..eb631a0b --- /dev/null +++ b/packages/b2c-cli/test/commands/am/orgs/audit.test.ts @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import OrgAudit from '../../../../src/commands/am/orgs/audit.js'; +import { + stubCommandConfigAndLogger, + stubJsonEnabled, + makeCommandThrowOnError, + stubImplicitOAuthStrategy, +} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +/** + * Unit tests for org audit command CLI logic. + * Tests output formatting, error handling, column selection. + * SDK tests cover the actual API calls. + */ +describe('org audit', () => { + const server = setupServer(); + + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1'], + twoFAEnabled: true, + vaasEnabled: false, + sfIdentityFederation: true, + }; + + const mockAuditLogs = [ + { + timestamp: '2025-01-15T10:30:45Z', + authorDisplayName: 'John Doe', + authorEmail: 'john.doe@example.com', + eventType: 'USER_CREATED', + eventMessage: 'User created successfully', + }, + { + timestamp: '2025-01-16T14:20:30Z', + authorDisplayName: 'Jane Smith', + authorEmail: 'jane.smith@example.com', + eventType: 'USER_UPDATED', + eventMessage: 'User updated', + }, + ]; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require org as argument', () => { + expect(OrgAudit.args).to.have.property('org'); + expect(OrgAudit.args.org.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OrgAudit.description).to.be.a('string'); + expect(OrgAudit.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OrgAudit.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return audit logs in JSON mode', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].eventType).to.equal('USER_CREATED'); + expect(result.content![0].authorDisplayName).to.equal('John Doe'); + }); + + it('should return audit logs in non-JSON mode', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle empty audit logs', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should get organization by name when ID lookup fails', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'Test Organization'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/Test%20Organization`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [mockOrg]}); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle 404 errors with custom message', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'nonexistent-org'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/nonexistent-org`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + expect(errorMessage).to.include('not found'); + } + }); + + it('should handle column selection with --columns flag', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + (command as any).flags = {columns: 'timestamp,authorDisplayName,eventType'}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle extended columns with --extended flag', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + (command as any).flags = {extended: true}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should format timestamps correctly', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + + const logsWithTimestamps = [ + { + timestamp: '2025-07-31T10:30:45Z', + authorDisplayName: 'Test User', + authorEmail: 'test@example.com', + eventType: 'TEST_EVENT', + eventMessage: 'Test message', + }, + ]; + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: logsWithTimestamps}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.have.lengthOf(1); + expect(result.content![0].timestamp).to.equal('2025-07-31T10:30:45Z'); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/orgs/get.test.ts b/packages/b2c-cli/test/commands/am/orgs/get.test.ts new file mode 100644 index 00000000..6b310e32 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/orgs/get.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import OrgGet from '../../../../src/commands/am/orgs/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for org get command CLI logic. + * Tests output formatting, error handling. + * SDK tests cover the actual API calls. + */ +describe('org get', () => { + const server = setupServer(); + + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1', 'realm2'], + emailsDomains: ['example.com'], + twoFARoles: ['role1', 'role2'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP', 'SMS'], + vaasEnabled: false, + sfIdentityFederation: true, + passwordMinEntropy: 8, + passwordHistorySize: 5, + passwordDaysExpiration: 90, + contactUsers: ['user-1', 'user-2'], + sfAccountIds: ['account-1', 'account-2'], + }; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require org as argument', () => { + expect(OrgGet.args).to.have.property('org'); + expect(OrgGet.args.org.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OrgGet.description).to.be.a('string'); + expect(OrgGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OrgGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return organization data in JSON mode', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOrg); + expect(result.id).to.equal('org-123'); + expect(result.name).to.equal('Test Organization'); + }); + + it('should return organization data in non-JSON mode', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOrg); + }); + + it('should get organization by name when ID lookup fails', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'Test Organization'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/Test%20Organization`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [mockOrg]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOrg); + expect(result.name).to.equal('Test Organization'); + }); + + it('should handle 404 errors with custom message', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'nonexistent-org'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/nonexistent-org`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + expect(errorMessage).to.include('not found'); + } + }); + + it('should handle organization with password policy attributes', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const orgWithPasswordPolicy = { + ...mockOrg, + passwordMinEntropy: 10, + passwordHistorySize: 3, + passwordDaysExpiration: 60, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(orgWithPasswordPolicy); + }), + ); + + const result = await command.run(); + + expect(result.passwordMinEntropy).to.equal(10); + expect(result.passwordHistorySize).to.equal(3); + expect(result.passwordDaysExpiration).to.equal(60); + }); + + it('should handle organization without password policy attributes', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const orgWithoutPasswordPolicy = { + ...mockOrg, + passwordMinEntropy: undefined, + passwordHistorySize: undefined, + passwordDaysExpiration: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(orgWithoutPasswordPolicy); + }), + ); + + const result = await command.run(); + + expect(result.passwordMinEntropy).to.be.undefined; + expect(result.passwordHistorySize).to.be.undefined; + expect(result.passwordDaysExpiration).to.be.undefined; + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/orgs/list.test.ts b/packages/b2c-cli/test/commands/am/orgs/list.test.ts new file mode 100644 index 00000000..758b2111 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/orgs/list.test.ts @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import OrgList from '../../../../src/commands/am/orgs/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for org list command CLI logic. + * Tests column selection, pagination validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('org list', () => { + const server = setupServer(); + + const mockOrgs = [ + { + id: 'org-1', + name: 'Organization 1', + realms: ['realm1', 'realm2'], + emailsDomains: ['example.com'], + twoFARoles: ['role1'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP'], + vaasEnabled: false, + sfIdentityFederation: true, + passwordMinEntropy: 8, + }, + { + id: 'org-2', + name: 'Organization 2', + realms: ['realm3'], + emailsDomains: ['test.com', 'example.org'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: true, + sfIdentityFederation: false, + passwordMinEntropy: 12, + }, + ]; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(OrgList.description).to.be.a('string'); + expect(OrgList.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OrgList.enableJsonFlag).to.be.true; + }); + }); + + describe('pagination validation', () => { + it('should validate size parameter - minimum', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {size: 0}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Size must be at least 1/); + } + }); + + it('should validate size parameter - maximum', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {size: 5001}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Size cannot exceed 5000/); + } + }); + + it('should validate page parameter - negative', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {page: -1}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page must be a non-negative integer/); + } + }); + }); + + describe('output formatting', () => { + it('should return organization collection in JSON mode', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].id).to.equal('org-1'); + expect(result.content![0].name).to.equal('Organization 1'); + }); + + it('should handle empty results', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should return data in non-JSON mode', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should use default pagination when not specified', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 0, size: 25}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('25'); + expect(capturedPage).to.equal('0'); + }); + + it('should pass custom pagination parameters', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {size: 50, page: 2}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 2, size: 50}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('50'); + expect(capturedPage).to.equal('2'); + }); + + it('should use max page size when --all flag is set', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {all: true}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 0, size: 5000}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('5000'); + }); + + it('should handle column selection with --columns flag', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {columns: 'id,name,realms'}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle extended columns with --extended flag', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {extended: true}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/roles/get.test.ts b/packages/b2c-cli/test/commands/am/roles/get.test.ts new file mode 100644 index 00000000..eefc47f8 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/roles/get.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import RoleGet from '../../../../src/commands/am/roles/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role get command CLI logic. + * Tests output formatting. + * SDK tests cover the actual API calls. + */ +describe('role get', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require roleId as argument', () => { + expect(RoleGet.args).to.have.property('roleId'); + expect(RoleGet.args.roleId.required).to.be.true; + }); + + it('should have correct description', () => { + expect(RoleGet.description).to.be.a('string'); + expect(RoleGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return role data in JSON mode', async () => { + const command = new RoleGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {roleId: 'bm-admin'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockRole); + expect(result.id).to.equal('bm-admin'); + }); + + it('should return role data in non-JSON mode', async () => { + const command = new RoleGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {roleId: 'bm-admin'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1'], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockRole); + }); + + it('should handle API errors', async () => { + const command = new RoleGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {roleId: 'nonexistent'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Role not found'}}, {status: 404}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + // The error should either be the custom 404 message or the API error message + expect(errorMessage).to.include('not found'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/roles/grant.test.ts b/packages/b2c-cli/test/commands/am/roles/grant.test.ts new file mode 100644 index 00000000..2487375a --- /dev/null +++ b/packages/b2c-cli/test/commands/am/roles/grant.test.ts @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import RoleGrant from '../../../../src/commands/am/roles/grant.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role grant command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('role grant', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(RoleGrant.args).to.have.property('login'); + expect(RoleGrant.args.login.required).to.be.true; + }); + + it('should require role flag', () => { + expect(RoleGrant.flags).to.have.property('role'); + expect(RoleGrant.flags.role.required).to.be.true; + }); + + it('should have correct description', () => { + expect(RoleGrant.description).to.be.a('string'); + expect(RoleGrant.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleGrant.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when user has no ID', async () => { + const command = new RoleGrant([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should grant role without scope', async () => { + const command = new RoleGrant([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: [], + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + }; + + let getUserCallCount = 0; + let updateUserCallCount = 0; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + getUserCallCount++; + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + updateUserCallCount++; + const body = (await request.json()) as {roles?: string[]}; + expect(body.roles).to.include('bm-admin'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + expect(getUserCallCount).to.equal(1); + expect(updateUserCallCount).to.equal(1); + }); + + it('should grant role with scope', async () => { + const command = new RoleGrant([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin', scope: 'tenant1,tenant2'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: [], + roleTenantFilter: '', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('bm-admin:tenant1,tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/roles/list.test.ts b/packages/b2c-cli/test/commands/am/roles/list.test.ts new file mode 100644 index 00000000..7acd871d --- /dev/null +++ b/packages/b2c-cli/test/commands/am/roles/list.test.ts @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import RoleList from '../../../../src/commands/am/roles/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role list command CLI logic. + * Tests column selection, pagination validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('role list', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(RoleList.description).to.be.a('string'); + expect(RoleList.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleList.enableJsonFlag).to.be.true; + }); + }); + + describe('getSelectedColumns', () => { + it('should return default columns when no flags provided', () => { + const command = new RoleList([], {} as any); + (command as any).flags = {}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['id', 'description', 'roleEnumName']); + }); + + it('should return all columns when --extended flag is set', () => { + const command = new RoleList([], {} as any); + (command as any).flags = {extended: true}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.include('id'); + expect(columns).to.include('permissions'); + }); + + it('should return custom columns when --columns flag is set', () => { + const command = new RoleList([], {} as any); + (command as any).flags = {columns: 'id,description,scope'}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['id', 'description', 'scope']); + }); + }); + + describe('pagination validation', () => { + it('should validate size parameter - minimum', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {size: 0}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate size parameter - maximum', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {size: 5000}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate page parameter - negative', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {page: -1}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page number must be a non-negative integer/); + } + }); + }); + + describe('output formatting', () => { + it('should return role collection in JSON mode', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }, + { + id: 'bm-user', + description: 'Business Manager User', + roleEnumName: 'ECOM_USER', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: true, + permissions: [], + }, + ], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].id).to.equal('bm-admin'); + }); + + it('should handle empty results', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should pass target type filter', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {'target-type': 'User'}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('roleTargetType')).to.equal('User'); + return HttpResponse.json({content: []}); + }), + ); + + await command.run(); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/roles/revoke.test.ts b/packages/b2c-cli/test/commands/am/roles/revoke.test.ts new file mode 100644 index 00000000..0db75a12 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/roles/revoke.test.ts @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import RoleRevoke from '../../../../src/commands/am/roles/revoke.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role revoke command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('role revoke', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(RoleRevoke.args).to.have.property('login'); + expect(RoleRevoke.args.login.required).to.be.true; + }); + + it('should require role flag', () => { + expect(RoleRevoke.flags).to.have.property('role'); + expect(RoleRevoke.flags.role.required).to.be.true; + }); + + it('should have correct description', () => { + expect(RoleRevoke.description).to.be.a('string'); + expect(RoleRevoke.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleRevoke.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when user has no ID', async () => { + const command = new RoleRevoke([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should revoke role without scope', async () => { + const command = new RoleRevoke([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: [], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {roles?: string[]}; + // When revoking a role without scope, roles might be undefined if empty + if (body.roles) { + expect(body.roles).to.not.include('bm-admin'); + } + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + + it('should revoke role with scope', async () => { + const command = new RoleRevoke([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin', scope: 'tenant1'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant2', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('tenant2'); + expect(body.roleTenantFilter).to.not.include('tenant1'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/users/create.test.ts b/packages/b2c-cli/test/commands/am/users/create.test.ts new file mode 100644 index 00000000..714cdc18 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/users/create.test.ts @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import UserCreate from '../../../../src/commands/am/users/create.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user create command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user create', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have required flags', () => { + expect(UserCreate.flags).to.have.property('org'); + expect(UserCreate.flags).to.have.property('mail'); + expect(UserCreate.flags).to.have.property('first-name'); + expect(UserCreate.flags).to.have.property('last-name'); + expect(UserCreate.flags.org.required).to.be.true; + expect(UserCreate.flags.mail.required).to.be.true; + expect(UserCreate.flags['first-name'].required).to.be.true; + expect(UserCreate.flags['last-name'].required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserCreate.description).to.be.a('string'); + expect(UserCreate.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserCreate.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return created user in JSON mode', async () => { + const command = new UserCreate([], {} as any); + (command as any).flags = { + org: 'org-123', + mail: 'newuser@example.com', + 'first-name': 'John', + 'last-name': 'Doe', + }; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-123'], + primaryOrganization: 'org-123', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.post(`${BASE_URL}/users`, async ({request}) => { + const body = (await request.json()) as {mail?: string; firstName?: string; lastName?: string}; + expect(body.mail).to.equal('newuser@example.com'); + return HttpResponse.json(mockUser, {status: 201}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + expect(result.mail).to.equal('newuser@example.com'); + }); + + it('should return created user in non-JSON mode', async () => { + const command = new UserCreate([], {} as any); + (command as any).flags = { + org: 'org-123', + mail: 'newuser@example.com', + 'first-name': 'John', + 'last-name': 'Doe', + }; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.post(`${BASE_URL}/users`, () => { + return HttpResponse.json(mockUser, {status: 201}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + }); + + it('should handle API errors', async () => { + const command = new UserCreate([], {} as any); + (command as any).flags = { + org: 'org-123', + mail: 'newuser@example.com', + 'first-name': 'John', + 'last-name': 'Doe', + }; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.post(`${BASE_URL}/users`, () => { + return HttpResponse.json({error: {message: 'User already exists'}}, {status: 400}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('User already exists'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/users/delete.test.ts b/packages/b2c-cli/test/commands/am/users/delete.test.ts new file mode 100644 index 00000000..e4d62395 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/users/delete.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import UserDelete from '../../../../src/commands/am/users/delete.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user delete command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user delete', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserDelete.args).to.have.property('login'); + expect(UserDelete.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserDelete.description).to.be.a('string'); + expect(UserDelete.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserDelete.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when user has no ID', async () => { + const command = new UserDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {purge: false}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should delete user (soft delete)', async () => { + const command = new UserDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {purge: false}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + }; + + let deleteCalled = false; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.post(`${BASE_URL}/users/user-123/disable`, () => { + deleteCalled = true; + return HttpResponse.json({}); + }), + ); + + await command.run(); + + expect(deleteCalled).to.be.true; + }); + + it('should purge user (hard delete)', async () => { + const command = new UserDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {purge: true}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + }; + + let purgeCalled = false; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.delete(`${BASE_URL}/users/user-123`, () => { + purgeCalled = true; + return HttpResponse.json({}); + }), + ); + + await command.run(); + + expect(purgeCalled).to.be.true; + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/users/get.test.ts b/packages/b2c-cli/test/commands/am/users/get.test.ts new file mode 100644 index 00000000..838f8822 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/users/get.test.ts @@ -0,0 +1,466 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import UserGet from '../../../../src/commands/am/users/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Helper function to extract expand values from URL search parameters. + * Reduces callback nesting in tests. + */ +function extractExpandValues(url: URL): string[] { + const expandParam = url.searchParams.get('expand'); + const allExpandParams = url.searchParams.getAll('expand'); + const expandValues: string[] = []; + if (expandParam) { + expandValues.push(...expandParam.split(',')); + } + for (const param of allExpandParams) { + expandValues.push(...param.split(',')); + } + // Remove duplicates and empty strings + return [...new Set(expandValues.filter(Boolean))]; +} + +/** + * Unit tests for user get command CLI logic. + * Tests output formatting. + * SDK tests cover the actual API calls. + */ +describe('user get', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserGet.args).to.have.property('login'); + expect(UserGet.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserGet.description).to.be.a('string'); + expect(UserGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return user data in JSON mode', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + userState: 'ACTIVE', + primaryOrganization: 'org-123', + passwordExpirationTimestamp: null, + verifiers: [], + linkedToSfIdentity: false, + lastLoginDate: '2025-01-01', + createdAt: 1_234_567_890, + lastModified: 1_234_567_890, + roles: ['bm-admin'], + organizations: ['org-123'], + roleTenantFilterMap: { + 'bm-admin': 'tenant1,tenant2', + }, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + expect(result.mail).to.equal('user@example.com'); + }); + + it('should return user data in non-JSON mode', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + }); + + it('should handle API errors', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'nonexistent@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages - return empty result + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); + + describe('expand functionality', () => { + it('should support --expand-all flag', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {'expand-all': true}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + const expandedUser = { + ...mockUser, + organizations: [{id: 'org-123', name: 'Test Org'}], + roles: [{id: 'bm-admin', description: 'Admin Role'}], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + // Verify expand parameter is passed + const url = new URL(request.url); + const uniqueValues = extractExpandValues(url); + expect(uniqueValues).to.include('organizations'); + expect(uniqueValues).to.include('roles'); + return HttpResponse.json(expandedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(expandedUser); + }); + + it('should support --expand with valid single value', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'organizations'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + const expandedUser = { + ...mockUser, + organizations: [{id: 'org-123', name: 'Test Org'}], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + const url = new URL(request.url); + const uniqueValues = extractExpandValues(url); + expect(uniqueValues).to.include('organizations'); + expect(uniqueValues).to.not.include('roles'); + return HttpResponse.json(expandedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(expandedUser); + }); + + it('should support --expand with comma-separated values', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'organizations,roles'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + const expandedUser = { + ...mockUser, + organizations: [{id: 'org-123', name: 'Test Org'}], + roles: [{id: 'bm-admin', description: 'Admin Role'}], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + const url = new URL(request.url); + const uniqueValues = extractExpandValues(url); + expect(uniqueValues).to.include('organizations'); + expect(uniqueValues).to.include('roles'); + return HttpResponse.json(expandedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(expandedUser); + }); + + it('should reject invalid expand values', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'invalid'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Invalid expand value'); + expect((error as Error).message).to.include('invalid'); + expect((error as Error).message).to.include('organizations'); + expect((error as Error).message).to.include('roles'); + } + }); + + it('should reject mixed valid and invalid expand values', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'organizations,invalid'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Invalid expand value'); + expect((error as Error).message).to.include('invalid'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/users/list.test.ts b/packages/b2c-cli/test/commands/am/users/list.test.ts new file mode 100644 index 00000000..39efa945 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/users/list.test.ts @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import UserList from '../../../../src/commands/am/users/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user list command CLI logic. + * Tests column selection, pagination validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user list', () => { + const server = setupServer(); + + const mockUsers = [ + { + id: 'user-1', + mail: 'user1@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + passwordExpirationTimestamp: null, + verifiers: [], + linkedToSfIdentity: false, + lastLoginDate: '2025-01-01', + }, + { + id: 'user-2', + mail: 'user2@example.com', + firstName: 'Jane', + lastName: 'Smith', + userState: 'INITIAL', + passwordExpirationTimestamp: Date.now() - 1000, + verifiers: [{id: 'verifier-1'}], + linkedToSfIdentity: true, + lastLoginDate: null, + }, + ]; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(UserList.description).to.be.a('string'); + expect(UserList.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserList.enableJsonFlag).to.be.true; + }); + }); + + describe('getSelectedColumns', () => { + it('should return default columns when no flags provided', () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal([ + 'mail', + 'firstName', + 'lastName', + 'userState', + 'passwordExpired', + 'twoFAEnabled', + 'linkedToSfIdentity', + 'lastLoginDate', + ]); + }); + + it('should return all columns when --extended flag is set', () => { + const command = new UserList([], {} as any); + (command as any).flags = {extended: true}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.include('mail'); + expect(columns).to.include('roles'); + expect(columns).to.include('organizations'); + }); + + it('should return custom columns when --columns flag is set', () => { + const command = new UserList([], {} as any); + (command as any).flags = {columns: 'mail,firstName,userState'}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['mail', 'firstName', 'userState']); + }); + }); + + describe('pagination validation', () => { + it('should validate size parameter - minimum', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {size: 0}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate size parameter - maximum', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {size: 5000}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate page parameter - negative', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {page: -1}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page number must be a non-negative integer/); + } + }); + }); + + describe('output formatting', () => { + it('should return user collection in JSON mode', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: mockUsers}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].mail).to.equal('user1@example.com'); + }); + + it('should handle empty results', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should return data in non-JSON mode', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: mockUsers}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should use default pagination when not specified', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: []}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('20'); + expect(capturedPage).to.equal('0'); + }); + + it('should pass custom pagination parameters', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {size: 50, page: 2}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: []}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('50'); + expect(capturedPage).to.equal('2'); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/am/users/update.test.ts b/packages/b2c-cli/test/commands/am/users/update.test.ts new file mode 100644 index 00000000..7f5fcc21 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/users/update.test.ts @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import UserUpdate from '../../../../src/commands/am/users/update.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user update command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user update', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserUpdate.args).to.have.property('login'); + expect(UserUpdate.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserUpdate.description).to.be.a('string'); + expect(UserUpdate.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserUpdate.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when no changes specified', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/No changes specified/); + } + }); + + it('should error when user has no ID', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {'first-name': 'Jane'}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should return updated user in JSON mode', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {'first-name': 'Jane'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'Jane', + lastName: 'Doe', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {firstName?: string}; + expect(body.firstName).to.equal('Jane'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + expect(result.firstName).to.equal('Jane'); + }); + + it('should return updated user in non-JSON mode', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {'last-name': 'Smith'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Smith', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {lastName?: string}; + expect(body.lastName).to.equal('Smith'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + }); +}); diff --git a/packages/b2c-cli/test/helpers/test-setup.ts b/packages/b2c-cli/test/helpers/test-setup.ts index 4c7a6601..53e3180c 100644 --- a/packages/b2c-cli/test/helpers/test-setup.ts +++ b/packages/b2c-cli/test/helpers/test-setup.ts @@ -8,8 +8,19 @@ import type {Config} from '@oclif/core'; import {captureOutput} from '@oclif/test'; import sinon from 'sinon'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; import {stubParse} from './stub-parse.js'; +type TokenResponse = { + accessToken: string; + expires: Date; + scopes: string[]; +}; + +function futureDate(minutes: number): Date { + return new Date(Date.now() + minutes * 60 * 1000); +} + /** * Run a command silently, capturing stdout/stderr. * Use this when you don't need to verify console output. @@ -70,3 +81,96 @@ export async function createTestCommand Promise}>( await command.init(); return command as T; } + +/** + * Stubs command config and logger for testing. + * @param command - The command instance to stub + * @param accountManagerHost - Account Manager hostname (default: 'account.test.demandware.com') + */ +export function stubCommandConfigAndLogger(command: any, accountManagerHost = 'account.test.demandware.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); + + Object.defineProperty(command, 'resolvedConfig', { + value: { + values: { + accountManagerHost, + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + hasOAuthConfig() { + return Boolean(this.values.clientId); + }, + }, + configurable: true, + }); +} + +/** + * Stubs the JSON enabled flag for a command. + * @param command - The command instance to stub + * @param enabled - Whether JSON mode is enabled + */ +export function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +/** + * Stubs a client property on a command. + * @param command - The command instance to stub + * @param propertyName - The name of the client property (e.g., 'accountManagerUsersClient', 'accountManagerRolesClient', 'accountManagerOrgsClient') + * @param client - The client instance to stub + */ +export function stubClient(command: any, propertyName: string, client: any): void { + Object.defineProperty(command, propertyName, { + get: () => client, + configurable: true, + }); +} + +/** + * Makes a command throw on error instead of using oclif's error handling. + * @param command - The command instance to modify + */ +export function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} + +/** + * Mocks getOAuthStrategy to return ImplicitOAuthStrategy with mocked implicitFlowLogin. + * This follows the pattern from oauth-implicit.test.ts to avoid browser-based OAuth flow. + * Use this for AM command tests that need to test implicit flow behavior without triggering + * the interactive browser-based authentication. + * + * @param command - The command instance to stub + * @param accountManagerHost - Account Manager hostname (default: 'account.test.demandware.com') + */ +export function stubImplicitOAuthStrategy(command: any, accountManagerHost = 'account.test.demandware.com'): void { + const strategy = new ImplicitOAuthStrategy({ + clientId: 'test-client-id', + accountManagerHost, + }); + + // Mock implicitFlowLogin to avoid browser-based OAuth flow (following oauth-implicit.test.ts pattern) + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'test-token', + expires: futureDate(30), + scopes: [], + }); + + // Stub getOAuthStrategy to return our mocked strategy + sinon.stub(command as {getOAuthStrategy: () => typeof strategy}, 'getOAuthStrategy').returns(strategy); +} diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index 53509ae9..f106192d 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -125,6 +125,121 @@ const result = await waitForJob(instance, 'my-job-id', execution.id); await siteArchiveImport(instance, './site-data.zip'); ``` +### Account Manager User Management + +```typescript +import { + getUserByLogin, + listUsers, + createUser, + updateUser, + deleteUser, + resetUser, + grantRole, + revokeRole, +} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {createAccountManagerUsersClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerUsersClient({}, auth); + +// List users with pagination +const users = await listUsers(client, {size: 25, page: 0}); + +// Get user by email +const user = await getUserByLogin(client, 'user@example.com'); + +// Create a new user +const newUser = await createUser(client, { + user: { + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', + }, +}); + +// Update a user +await updateUser(client, { + userId: user.id!, + changes: {firstName: 'Jane'}, +}); + +// Grant a role to a user +await grantRole(client, { + userId: user.id!, + role: 'bm-admin', + scope: 'tenant1,tenant2', // Optional tenant filter +}); + +// Reset user to INITIAL state +await resetUser(client, user.id!); +``` + +### Account Manager Role Management + +```typescript +import {getRole, listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import {createAccountManagerRolesClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerRolesClient({}, auth); + +// Get role details by ID +const role = await getRole(client, 'bm-admin'); + +// List all roles with pagination +const roles = await listRoles(client, {size: 25, page: 0}); + +// List roles filtered by target type +const userRoles = await listRoles(client, { + size: 25, + page: 0, + roleTargetType: 'User', +}); +``` + +### Account Manager Organization Management + +```typescript +import {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import {createAccountManagerOrgsClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerOrgsClient({}, auth); + +// Get organization by ID +const org = await getOrg(client, 'org-123'); + +// Get organization by name +const orgByName = await getOrgByName(client, 'My Organization'); + +// List organizations with pagination +const orgs = await listOrgs(client, {size: 25, page: 0}); + +// List all organizations (uses max page size of 5000) +const allOrgs = await listOrgs(client, {all: true}); + +// Get audit logs for an organization +const auditLogs = await getOrgAuditLogs(client, 'org-123'); +``` + ## Module Exports The SDK provides subpath exports for tree-shaking and organization: @@ -139,6 +254,9 @@ The SDK provides subpath exports for tree-shaking and organization: | `@salesforce/b2c-tooling-sdk/operations/code` | Code deployment operations | | `@salesforce/b2c-tooling-sdk/operations/jobs` | Job execution and site import/export | | `@salesforce/b2c-tooling-sdk/operations/sites` | Site management | +| `@salesforce/b2c-tooling-sdk/operations/users` | Account Manager user management | +| `@salesforce/b2c-tooling-sdk/operations/roles` | Account Manager role management | +| `@salesforce/b2c-tooling-sdk/operations/orgs` | Account Manager organization management | | `@salesforce/b2c-tooling-sdk/discovery` | Workspace type detection (PWA Kit, SFRA, etc.) | | `@salesforce/b2c-tooling-sdk/cli` | CLI utilities (BaseCommand, table rendering) | | `@salesforce/b2c-tooling-sdk/logging` | Structured logging utilities | diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 8f4169d1..7f45fcc7 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -123,6 +123,39 @@ "default": "./dist/cjs/operations/logs/index.js" } }, + "./operations/users": { + "development": "./src/operations/users/index.ts", + "import": { + "types": "./dist/esm/operations/users/index.d.ts", + "default": "./dist/esm/operations/users/index.js" + }, + "require": { + "types": "./dist/cjs/operations/users/index.d.ts", + "default": "./dist/cjs/operations/users/index.js" + } + }, + "./operations/roles": { + "development": "./src/operations/roles/index.ts", + "import": { + "types": "./dist/esm/operations/roles/index.d.ts", + "default": "./dist/esm/operations/roles/index.js" + }, + "require": { + "types": "./dist/cjs/operations/roles/index.d.ts", + "default": "./dist/cjs/operations/roles/index.js" + } + }, + "./operations/orgs": { + "development": "./src/operations/orgs/index.ts", + "import": { + "types": "./dist/esm/operations/orgs/index.d.ts", + "default": "./dist/esm/operations/orgs/index.js" + }, + "require": { + "types": "./dist/cjs/operations/orgs/index.d.ts", + "default": "./dist/cjs/operations/orgs/index.js" + } + }, "./cli": { "development": "./src/cli/index.ts", "import": { @@ -221,7 +254,7 @@ "data" ], "scripts": { - "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts", + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm && pnpm run build:cjs", "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml b/packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml new file mode 100644 index 00000000..73dafa49 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml @@ -0,0 +1,316 @@ +openapi: 3.0.1 +info: + title: Roles + description: | + # API Overview + + Retrieve information about roles within the Account Manager system. Roles define permissions and access levels that can be assigned to Users and API Clients. Roles can be global (applying across all instances) or instance-specific (applying to particular instances). + + ## Authentication & Authorization + + All requests to the Roles API must be authenticated using OAuth 2.0 bearer token authentication. The API supports two OAuth 2.0 flows: client credentials and authorization code. The token endpoint is available at `https://account.demandware.com/dwsso/oauth2/access_token`. + + ## Use Cases + + ### List Available Roles + + Retrieve a paginated list of all roles available in the system. Use the roleTargetType parameter to filter roles by whether they can be assigned to Users or API Clients. + + ### Get Role Details + + Retrieve detailed information about a specific role, including its permissions, scope, and target type. + version: 1.0.0-beta +servers: + - url: https://account.demandware.com + description: Account Manager Production Instance +security: + - AmOAuth2: [] +paths: + /dw/rest/v1/roles: + get: + operationId: getRoles + summary: List all roles. + description: Retrieve a paginated list of all roles. Use the roleTargetType parameter to filter by target type. + parameters: + - name: pageable + in: query + schema: + $ref: "#/components/schemas/Pageable" + - name: roleTargetType + in: query + schema: + type: string + enum: + - ApiClient + - User + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCollection" + example: + content: + - description: Business Manager User + roleEnumName: ECOM_USER + permissions: [] + scope: INSTANCE + targetType: User + id: bm-user + - description: Business Manager Administrator + roleEnumName: ECOM_ADMIN + permissions: [] + scope: INSTANCE + targetType: User + id: bm-admin + /dw/rest/v1/roles/{roleId}: + get: + operationId: getRole + summary: Get role by ID. + description: Retrieve a specific role by ID. + parameters: + - name: roleId + in: path + required: true + schema: + type: string + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: A role with this ID was not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + example: + description: Business Manager User + roleEnumName: ECOM_USER + permissions: [] + scope: INSTANCE + targetType: User + id: bm-user +components: + examples: + ErrorAuthenticationRequired: + summary: 401 Unauthorized + description: Response for `401 Unauthorized` status + value: > + { + "message": "Full authentication is required to access this resource", + "code": "InsufficientAuthenticationException", + "fieldErrors": null + } + responses: + UnauthorizedError: + description: Access token is missing or invalid + content: + application/json: + schema: + type: object + properties: + errors: + type: array + description: The list of errors + items: + type: object + properties: + message: + type: string + description: Error message + code: + type: string + description: Error code + fieldErrors: + type: array + nullable: true + description: Field-specific errors. + items: + type: object + properties: + field: + type: string + description: The field that contained the erroneous value. + rejectedValue: + type: object + description: The value that was rejected. + bindingFailure: + type: boolean + description: Whether this error was caused by failed binding (e.g. type mismatch). + example: + errors: + - message: "Full authentication is required to access this resource" + code: "InsufficientAuthenticationException" + fieldErrors: null + RateLimitedError: + description: Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + securitySchemes: + AmOAuth2: + type: oauth2 + description: Account Manager OAuth 2.0 bearer token authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + headers: + X-RateLimit-Limit: + schema: + type: integer + description: Rate limit per minute. + X-RateLimit-Remaining: + schema: + type: integer + description: The number of requests left in the current time window. + X-RateLimit-Reset: + schema: + type: string + format: date-time + description: The UTC timestamp at which the current rate limit window resets. + schemas: + Role: + type: object + description: A role defines permissions and access levels that can be assigned to Users and API Clients. + properties: + description: + type: string + description: Description of the role. + roleEnumName: + maxLength: 50 + minLength: 0 + type: string + description: Enumeration name of the role. + permissions: + uniqueItems: true + type: array + description: List of permissions granted by this role. + items: + type: string + scope: + type: string + description: Scope level of the role (global or instance-specific). + enum: + - GLOBAL + - INSTANCE + targetType: + type: string + nullable: true + description: Type of entity the role can be assigned to. + enum: + - ApiClient + - User + twoFAEnabled: + type: boolean + description: Indicates if two-factor authentication is required for the role. + id: + type: string + description: Unique identifier of the role. + RoleCollection: + type: object + description: A paginated collection of roles. + properties: + content: + type: array + items: + $ref: "#/components/schemas/Role" + Pageable: + type: object + description: Pagination parameters for list operations. + properties: + page: + minimum: 0 + type: integer + format: int32 + description: Zero-based page index. + size: + minimum: 1 + default: 20 + maximum: 4000 + type: integer + format: int32 + description: Number of items to return per page. + ErrorResponse: + type: object + description: Standard error response format returned when API requests fail. + properties: + errors: + type: array + description: The list of errors + items: + type: object + properties: + message: + type: string + description: Error message + code: + type: string + description: Error code + fieldErrors: + type: array + nullable: true + description: Field-specific errors + items: + type: object + properties: + field: + type: string + description: The field that contained the erroneous value + rejectedValue: + type: object + description: The value that was rejected + bindingFailure: + type: boolean + description: Whether this error was caused by failed binding (e.g. type mismatch) diff --git a/packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml b/packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml new file mode 100644 index 00000000..215bea92 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml @@ -0,0 +1,1102 @@ +openapi: 3.0.1 +info: + title: Users + description: | + # API Overview + + Manage user accounts within the Account Manager system. Users represent individual accounts with access to the platform. They can be assigned roles and organizations, have configurable authentication preferences, and support various lifecycle states (INITIAL, ENABLED, DELETED). + + ## Authentication & Authorization + + All requests to the Users API must be authenticated using OAuth 2.0 bearer token authentication. The API supports two OAuth 2.0 flows: client credentials and authorization code. The token endpoint is available at `https://account.demandware.com/dwsso/oauth2/access_token`. + + ## Use Cases + + ### Create and Manage User Accounts + + Create new user accounts, update user information, and manage user lifecycle states. Users are created in INITIAL state and must be activated before they can log in. + + ### Reset User Accounts + + Reset users to INITIAL state and send activation instructions, useful for password resets or account recovery scenarios. + + ### Disable and Purge Users + + Disable user accounts (setting state to DELETED) and purge users that have been disabled. Users must be in DELETED state before they can be purged. + version: 1.0.0-beta +servers: + - url: https://account.demandware.com + description: Account Manager Production Instance +security: + - AmOAuth2: [] +paths: + /dw/rest/v1/users: + get: + operationId: getUsers + summary: List all users. + description: Retrieve a paginated list of all users. + parameters: + - name: pageable + in: query + required: false + schema: + $ref: "#/components/schemas/Pageable" + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/UserCollection" + example: + content: + - id: "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c" + mail: "alice.johnson@example.com" + firstName: "Alice" + lastName: "Johnson" + displayName: "Alice Johnson" + mobilePhone: "+1-555-9876" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + organizations: + - "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + primaryOrganization: "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + userState: "ENABLED" + createdAt: "2024-01-15T10:30:00Z" + lastModified: "2024-11-20T14:22:00Z" + lastLoginDate: "2024-12-03" + linkedToSfIdentity: false + - id: "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a" + mail: "robert.williams@example.com" + firstName: "Robert" + lastName: "Williams" + displayName: "Robert Williams" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:wxyz_stg" + organizations: + - "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + primaryOrganization: "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + userState: "INITIAL" + createdAt: "2024-12-04T09:15:00Z" + linkedToSfIdentity: false + + post: + operationId: createUser + summary: Create user. + description: | + Create a new user with the specified properties. + Note: Users are created in INITIAL state. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + example: + mail: "john.doe@example.com" + firstName: "John" + lastName: "Doe" + primaryOrganization: "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6" + organizations: + - "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + required: true + responses: + "400": + description: Bad Request + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorCreatingUserFailed: + $ref: "#/components/examples/ErrorUserValidation" + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + 201: + description: Created + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + Location: + description: URL to read created user + schema: + type: string + format: uri + content: + application/json: + schema: + $ref: "#/components/schemas/UserRead" + example: + id: "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a" + mail: "robert.williams@example.com" + firstName: "Robert" + lastName: "Williams" + displayName: "Robert Williams" + primaryOrganization: "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + organizations: + - "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + userState: "INITIAL" + createdAt: "2024-12-04T09:15:00Z" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + /dw/rest/v1/users/{userId}: + get: + operationId: getUser + summary: Get user by ID. + description: Retrieve a specific user by ID. Use the expand parameter to retrieve more information on related organizations and roles. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + - name: expand + in: query + required: false + style: form + explode: false + example: organizations + description: Comma-separated list of fields that should be expanded in the response. Ensures that fully inlined organization and/or role objects get returned. + schema: + type: array + items: + type: string + enum: + - organizations + - roles + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: A user with this ID was not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/UserRead" + example: + id: "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c" + mail: "alice.johnson@example.com" + firstName: "Alice" + lastName: "Johnson" + displayName: "Alice Johnson" + mobilePhone: "+1-555-9876" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + organizations: + - "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + primaryOrganization: "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + userState: "ENABLED" + createdAt: "2024-01-15T10:30:00Z" + lastModified: "2024-11-20T14:22:00Z" + lastLoginDate: "2024-12-03" + linkedToSfIdentity: false + put: + operationId: updateUser + summary: Update user. + description: Apply a partial update to an existing user, i.e. an omitted field stays at its previous value, a contained field replaces the previously saved value. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserUpdate" + example: + firstName: "Jane" + lastName: "Smith" + displayName: "Jane Smith" + mobilePhone: "+1-555-0123" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + required: true + responses: + "400": + description: Bad Request + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorUpdatingUserFailed: + $ref: "#/components/examples/ErrorUserValidation" + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/UserRead" + delete: + operationId: purgeUser + summary: Purge user. + description: | + Purge a user. Only users in state Deleted may be purged. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: A user with this ID was not found. + "412": + description: Precondition Failed - User must be in status DELETED to be purged. + "429": + $ref: "#/components/responses/RateLimitedError" + "204": + description: User has been purged. + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + /dw/rest/v1/users/{userId}/reset: + post: + operationId: resetUser + summary: Reset user to INITIAL status + description: Reset a user to INITIAL status and send activation instructions. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserResetResource" + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: User not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "204": + description: User has been reset. + /dw/rest/v1/users/{userId}/disable: + post: + operationId: disableUser + summary: Disable user. + description: Disable a user, setting their userState to DELETED. This is a prerequisite for purging a user. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserDeactivationResource" + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: User not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "204": + description: User has been disabled. +components: + examples: + ErrorUserValidation: + summary: 400 Bad request + description: Response for `400 Bad request` status + value: > + { + "errors": [ + { + "message": "invalid argument User", + "code": "invalid argument User", + "fieldErrors": [ + { + "codes": [ + "ValidEmail.user.mail", + "ValidEmail.mail", + "ValidEmail.java.lang.String", + "ValidEmail" + ], + "arguments": null, + "defaultMessage": "Email 'null' invalid, reason 'Email address must not be null.'.", + "objectName": "user", + "field": "mail", + "rejectedValue": null, + "bindingFailure": false, + "code": "ValidEmail" + }, + { + "codes": [ + "NotNull.user.primaryOrganization", + "NotNull.primaryOrganization", + "NotNull.java.lang.String", + "NotNull" + ], + "arguments": null, + "defaultMessage": "must not be null", + "objectName": "user", + "field": "primaryOrganization", + "rejectedValue": null, + "bindingFailure": false, + "code": "NotNull" + }, + { + "codes": [ + "NotNull.user.lastName", + "NotNull.lastName", + "NotNull.java.lang.String", + "NotNull" + ], + "arguments": null, + "defaultMessage": "must not be null", + "objectName": "user", + "field": "lastName", + "rejectedValue": null, + "bindingFailure": false, + "code": "NotNull" + }, + { + "codes": [ + "NotNull.user.mail", + "NotNull.mail", + "NotNull.java.lang.String", + "NotNull" + ], + "arguments": null, + "defaultMessage": "must not be null", + "objectName": "user", + "field": "mail", + "rejectedValue": null, + "bindingFailure": false, + "code": "NotNull" + } + ] + } + ] + } + ErrorAuthenticationRequired: + summary: 401 Unauthorized + description: Response for `401 Unauthorized` status + value: > + { + "message": "Full authentication is required to access this resource", + "code": "InsufficientAuthenticationException", + "fieldErrors": null + } + responses: + RateLimitedError: + description: Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + securitySchemes: + AmOAuth2: + type: oauth2 + description: Account Manager OAuth 2.0 bearer token authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + headers: + X-RateLimit-Limit: + schema: + type: integer + description: Rate limit per minute. + X-RateLimit-Remaining: + schema: + type: integer + description: The number of requests left in the current time window. + X-RateLimit-Reset: + schema: + type: string + format: date-time + description: The UTC timestamp at which the current rate limit window resets. + schemas: + UserCreate: + type: object + description: Request body for creating a new user. + required: + - mail + - firstName + - lastName + - organizations + - primaryOrganization + properties: + mail: + type: string + description: Email address of the user. + firstName: + maxLength: 40 + minLength: 1 + type: string + description: First name of the user. + lastName: + maxLength: 40 + minLength: 1 + type: string + description: Last name of the user. + displayName: + maxLength: 100 + minLength: 1 + type: string + description: Display name of the user. + businessPhone: + type: string + nullable: true + description: Business phone number. + homePhone: + type: string + nullable: true + description: Home phone number. + mobilePhone: + type: string + nullable: true + description: Mobile phone number. + preferredLocale: + type: string + nullable: true + description: Preferred locale for the user. + enum: + - none + - de + - de_DE + - en + - en_CA + - en_US + - es + - fr + - fr_CA + - nl + roles: + uniqueItems: true + type: array + description: List of IDs of the roles this user possesses. + items: + type: string + title: Role ID + organizations: + uniqueItems: true + type: array + description: List of organization IDs this user belongs to. + items: + type: string + title: Organization ID + primaryOrganization: + type: string + description: Primary organization ID for the user. + roleTenantFilter: + $ref: "#/components/schemas/RoleTenantFilter" + supportTicketId: + type: string + x-internal: true + writeOnly: true + description: Only required for users of the Salesforce organization. + UserUpdate: + type: object + description: Request body for updating an existing user. + properties: + mail: + type: string + description: Email address of the user. + firstName: + maxLength: 40 + minLength: 1 + type: string + description: First name of the user. + lastName: + maxLength: 40 + minLength: 1 + type: string + description: Last name of the user. + displayName: + maxLength: 100 + minLength: 1 + type: string + description: Display name of the user. + businessPhone: + type: string + nullable: true + description: Business phone number. + homePhone: + type: string + nullable: true + description: Home phone number. + mobilePhone: + type: string + nullable: true + description: Mobile phone number. + preferredLocale: + type: string + nullable: true + description: Preferred locale for the user. + enum: + - none + - de + - de_DE + - en + - en_CA + - en_US + - es + - fr + - fr_CA + - nl + roles: + uniqueItems: true + type: array + description: List of IDs of the roles this user possesses. + items: + type: string + title: Role ID + organizations: + uniqueItems: true + type: array + description: List of organization IDs this user belongs to. + items: + type: string + title: Organization ID + primaryOrganization: + type: string + description: Primary organization ID for the user. + roleTenantFilter: + $ref: "#/components/schemas/RoleTenantFilter" + supportTicketId: + type: string + x-internal: true + writeOnly: true + description: Only required for users of the Salesforce organization. + UserRead: + type: object + description: User object returned in read operations. + properties: + mail: + type: string + description: Email address of the user. + firstName: + maxLength: 40 + minLength: 1 + type: string + description: First name of the user. + lastName: + maxLength: 40 + minLength: 1 + type: string + description: Last name of the user. + displayName: + maxLength: 100 + minLength: 1 + type: string + description: Display name of the user. + businessPhone: + type: string + nullable: true + description: Business phone number. + homePhone: + type: string + nullable: true + description: Home phone number. + mobilePhone: + type: string + nullable: true + description: Mobile phone number. + preferredLocale: + type: string + nullable: true + description: Preferred locale for the user. + enum: + - none + - de + - de_DE + - en + - en_CA + - en_US + - es + - fr + - fr_CA + - nl + roles: + uniqueItems: true + type: array + description: List of IDs of the roles this user possesses. + items: + oneOf: + - type: string + title: Role ID + - $ref: "#/components/schemas/Role" + organizations: + uniqueItems: true + type: array + description: List of organization IDs this user belongs to. + items: + oneOf: + - type: string + title: Organization ID + - $ref: "#/components/schemas/Organization" + primaryOrganization: + type: string + description: Primary organization ID for the user. + roleTenantFilter: + $ref: "#/components/schemas/RoleTenantFilter" + passwordExpirationTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp when the password expires. + passwordModificationTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp of the last password modification. + createdAt: + type: string + format: date-time + readOnly: true + nullable: true + description: Timestamp when the user was created. + lastModified: + type: string + format: date-time + readOnly: true + nullable: true + description: Timestamp of the last modification. + lastLoginDate: + type: string + format: date + readOnly: true + nullable: true + description: Date of the last successful login. + userState: + type: string + readOnly: true + description: Current state of the user account. + enum: + - INITIAL + - ENABLED + - DELETED + activationCodeCreationTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp when the activation code was created. + sfUserId: + type: string + readOnly: true + nullable: true + description: Salesforce user identifier. + verifiers: + type: array + readOnly: true + description: List of authentication verifiers (e.g., MFA devices). + items: + $ref: "#/components/schemas/Verifier" + deleteTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp when the user was marked for deletion. + roleTenantFilterMap: + type: object + readOnly: true + description: Map of role tenant filter assignments. + linkedToSfIdentity: + type: boolean + readOnly: true + description: Indicates if the user is linked to a Salesforce identity. + id: + type: string + format: uuid + readOnly: true + description: Unique identifier of the user. + supportTicketId: + type: string + x-internal: true + writeOnly: true + description: Only required for users of the Salesforce organization. + Verifier: + type: object + description: A verifier represents a two-factor authentication device or method associated with a user account. + required: + - displayName + - id + - status + - type + properties: + id: + type: string + description: Unique identifier of the verifier. + type: + type: string + description: Type of verifier (e.g., SMS, TOTP, WebAuthn). + displayName: + type: string + description: Human-readable name for the verifier. + status: + type: string + description: Current status of the verifier. + UserResetResource: + type: object + description: Request body for resetting a user to INITIAL state. + properties: + supportTicketId: + type: string + x-internal: true + description: Only required for users of the Salesforce organization. + UserDeactivationResource: + type: object + description: Request body for disabling a user. + properties: + supportTicketId: + type: string + x-internal: true + description: Only required for users of the Salesforce organization. + Pageable: + type: object + description: Pagination parameters for list operations. + properties: + page: + minimum: 0 + type: integer + format: int32 + description: Zero-based page index. + size: + minimum: 1 + default: 20 + maximum: 4000 + type: integer + format: int32 + description: Number of items to return per page. + UserCollection: + type: object + description: A paginated collection of users. + properties: + content: + type: array + items: + $ref: "#/components/schemas/UserRead" + Role: + type: object + description: A role defines permissions and access levels that can be assigned to Users and API Clients. + properties: + description: + type: string + description: Description of the role. + roleEnumName: + maxLength: 50 + minLength: 0 + type: string + description: Enumeration name of the role. + permissions: + uniqueItems: true + type: array + description: List of permissions granted by this role. + items: + type: string + scope: + type: string + description: Scope level of the role (global or instance-specific). + enum: + - GLOBAL + - INSTANCE + targetType: + type: string + nullable: true + description: Type of entity this role can be assigned to. + enum: + - ApiClient + - User + twoFAEnabled: + type: boolean + description: Indicates if two-factor authentication is required for this role. + id: + type: string + description: Unique identifier of the role. + Organization: + type: object + description: An organization represents a customer, partner, or internal entity within the Account Manager system. + properties: + name: + type: string + description: Name of the organization. + contactUsers: + type: array + description: List of contact user IDs. + items: + type: string + realms: + type: array + description: List of realm identifiers. + items: + type: string + passwordMinEntropy: + type: integer + description: Minimum password entropy requirement. + passwordHistorySize: + type: integer + description: Number of previous passwords to remember. + passwordDaysExpiration: + type: integer + description: Number of days until password expires. + sfAccountIds: + type: array + description: Salesforce account identifiers. + items: + type: string + type: + type: string + description: Type of organization. + enum: + - CUSTOMER + - PARTNER + - INTERNAL + twoFARoles: + type: array + description: List of role IDs that require two-factor authentication. + items: + type: string + twoFAEnabled: + type: boolean + description: Indicates if two-factor authentication is enabled for the organization. + sfMyDomain: + type: string + nullable: true + description: Salesforce My Domain name. + sfMyDomainSuffix: + type: string + description: Salesforce My Domain suffix. + sfMyDomainVerified: + type: boolean + description: Indicates if Salesforce My Domain is verified. + sfMyDomainVerificationTimestamp: + type: string + format: date-time + nullable: true + description: Timestamp when Salesforce My Domain was verified. + sfIdentityFederation: + type: string + description: Salesforce identity federation status. + enum: + - DISABLED + - ENABLED + justInTimeUserProvisioningEnabled: + type: boolean + description: Indicates if just-in-time user provisioning is enabled. + allowedVerifierTypes: + type: array + description: List of allowed verifier types for authentication. + items: + type: string + disableInactiveUsers: + type: boolean + description: Indicates if inactive users should be automatically disabled. + inactiveUserDays: + type: integer + description: Number of days before a user is considered inactive. + id: + type: string + readOnly: true + description: Unique identifier of the organization. + ErrorResponse: + type: object + description: Standard error response format returned when API requests fail. + properties: + errors: + type: array + description: The list of errors. + items: + type: object + properties: + message: + type: string + description: Error message. + code: + type: string + description: Error code. + fieldErrors: + type: array + nullable: true + description: Field-specific errors. + items: + type: object + properties: + codes: + type: array + items: + type: string + description: List of error codes. + arguments: + nullable: true + description: Arguments for the error message. + defaultMessage: + type: string + description: Default error message. + objectName: + type: string + description: Name of the object that failed validation. + field: + type: string + description: The field that contained the erroneous value. + rejectedValue: + nullable: true + description: The value that was rejected. + bindingFailure: + type: boolean + description: Whether this error was caused by failed binding (e.g. type mismatch). + code: + type: string + description: Error code. + RoleTenantFilter: + type: string + pattern: '(\w+:\w{4,}_\w{3,}(,\w{4,}_\w{3,})*(;)?)*' + description: | + Filter for role tenant assignments. Format: ROLE_ENUM_NAME:instance_id,instance_id;ROLE_ENUM_NAME:instance_id + - Role enum names are separated by semicolons (;) + - Each role enum name is followed by a colon (:) and its tenant filters + - Tenant filters are comma-separated (,) + - Each tenant filter consists of a 4-character realm and 3-character instance_id separated by underscore (_) + - A special case is an instance_id ending in _sbx, as it gives access to all sandboxes of a realm + + Example: CC_USER:aabc_prd,aabc_t12;LOGCENTER_USER:aamn_sbx diff --git a/packages/b2c-tooling-sdk/src/cli/am-command.ts b/packages/b2c-tooling-sdk/src/cli/am-command.ts new file mode 100644 index 00000000..10d5f52b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/am-command.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from './oauth-command.js'; +import {createAccountManagerClient} from '../clients/am-api.js'; +import type {AccountManagerClient} from '../clients/am-api.js'; +import type {AuthMethod} from './config.js'; + +/** + * Base command for Account Manager operations. + * + * Extends OAuthCommand with Account Manager client setup for users, roles, and organizations. + * Overrides default auth methods to prioritize implicit flow for Account Manager operations. + * + * @example + * export default class UserList extends AmCommand { + * async run(): Promise { + * const users = await this.accountManagerClient.listUsers({}); + * // ... + * } + * } + * + * @example + * export default class OrgList extends AmCommand { + * async run(): Promise { + * const orgs = await this.accountManagerClient.listOrgs(); + * // ... + * } + * } + */ +export abstract class AmCommand extends OAuthCommand { + /** + * Override default auth methods to prioritize implicit flow for Account Manager. + * Gets the default methods from parent class, then ensures 'implicit' is first. + * If 'implicit' is already present, moves it to first position. + * If 'implicit' is not present, prepends it to the beginning. + */ + protected override getDefaultAuthMethods(): AuthMethod[] { + const defaultMethods = super.getDefaultAuthMethods(); + const implicitIndex = defaultMethods.indexOf('implicit'); + + if (implicitIndex === 0) { + // Already first, return as-is + return defaultMethods; + } + + if (implicitIndex > 0) { + // Implicit exists but not first - move it to first + const methods = [...defaultMethods]; + methods.splice(implicitIndex, 1); + return ['implicit', ...methods]; + } + + // Implicit not present - prepend it + return ['implicit', ...defaultMethods]; + } + private _accountManagerClient?: AccountManagerClient; + + /** + * Gets the unified Account Manager client, creating it if necessary. + * This provides direct access to all Account Manager API methods (users, roles, orgs). + * + * @example + * const client = this.accountManagerClient; + * const users = await client.listUsers({}); + * const roles = await client.listRoles({}); + * const orgs = await client.listOrgs(); + * const user = await client.getUser('user-id'); + * const role = await client.getRole('bm-admin'); + * const org = await client.getOrg('org-id'); + */ + protected get accountManagerClient(): AccountManagerClient { + if (!this._accountManagerClient) { + this.requireOAuthCredentials(); + const authStrategy = this.getOAuthStrategy(); + this._accountManagerClient = createAccountManagerClient( + { + hostname: this.accountManagerHost, + }, + authStrategy, + ); + } + return this._accountManagerClient; + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 8d821d4c..f25484c5 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -99,6 +99,7 @@ export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; +export {AmCommand} from './am-command.js'; export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts index 93c971d7..4b8bb1b4 100644 --- a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts @@ -13,6 +13,12 @@ import {ImplicitOAuthStrategy} from '../auth/oauth-implicit.js'; import {t} from '../i18n/index.js'; import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +/** + * Default OAuth authentication methods array used by getOAuthStrategy. + * Extracted from getOAuthStrategy() to ensure getDefaultAuthMethods() returns the same array. + */ +const DEFAULT_OAUTH_AUTH_METHODS: AuthMethod[] = ['client-credentials', 'implicit']; + /** * Base command for operations requiring OAuth authentication. * Use this for platform-level operations like ODS, APIs. @@ -85,6 +91,17 @@ export abstract class OAuthCommand extends BaseCommand return this.resolvedConfig.values.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; } + /** + * Gets the default authentication methods in priority order. + * This method is used by getOAuthStrategy() when no auth methods are specified in config. + * Subclasses can override this to change the default priority. + * + * @returns Array of auth methods in priority order (first is highest priority) + */ + protected getDefaultAuthMethods(): AuthMethod[] { + return DEFAULT_OAUTH_AUTH_METHODS; + } + /** * Gets an OAuth auth strategy based on allowed auth methods and available credentials. * @@ -96,8 +113,8 @@ export abstract class OAuthCommand extends BaseCommand protected getOAuthStrategy(): OAuthStrategy | ImplicitOAuthStrategy { const config = this.resolvedConfig.values; const accountManagerHost = this.accountManagerHost; - // Default to client-credentials and implicit if no methods specified - const allowedMethods = config.authMethods || (['client-credentials', 'implicit'] as AuthMethod[]); + // Use getDefaultAuthMethods() to get default array, allowing subclasses to override + const allowedMethods = config.authMethods || this.getDefaultAuthMethods(); for (const method of allowedMethods) { switch (method) { diff --git a/packages/b2c-tooling-sdk/src/clients/am-api.ts b/packages/b2c-tooling-sdk/src/clients/am-api.ts new file mode 100644 index 00000000..e8bb0ced --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-api.ts @@ -0,0 +1,1188 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager API client for B2C Commerce. + * + * Provides clients for the Account Manager REST APIs including users, roles, and organizations. + * Uses openapi-fetch with OAuth authentication middleware for users and roles, + * and fetch with OAuth for organizations. + * + * @module clients/am-api + */ +import createClient, {type Client, type Middleware} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type { + paths as UsersPaths, + components as UsersComponents, + operations as UsersOperations, +} from './am-users-api.generated.js'; +import type {paths as RolesPaths, components as RolesComponents} from './am-roles-api.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +import {getLogger} from '../logging/logger.js'; + +// ============================================================================ +// Users API +// ============================================================================ + +/** + * The typed Account Manager Users client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createAccountManagerUsersClient} for instantiation + */ +export type AccountManagerUsersClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type AccountManagerResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Account Manager error response type from the generated schema. + */ +export type AccountManagerError = UsersComponents['schemas']['ErrorResponse']; + +/** + * User type from the generated schema. + */ +export type AccountManagerUser = UsersComponents['schemas']['UserRead']; +export type UserCreate = UsersComponents['schemas']['UserCreate']; +export type UserUpdate = UsersComponents['schemas']['UserUpdate']; + +/** + * Expand parameter type for user operations. + * Extracted from the generated API types to ensure consistency. + */ +export type UserExpandOption = NonNullable< + NonNullable['expand'] +>[number]; +export type UserCollection = UsersComponents['schemas']['UserCollection']; +export type UserState = 'INITIAL' | 'ENABLED' | 'DELETED'; + +/** + * Options for listing users. + */ +export interface ListUsersOptions { + /** Page size (default: 20, min: 1, max: 4000) */ + size?: number; + /** Page number (default: 0) */ + page?: number; +} + +/** + * Role name mappings between external and internal formats. + */ +export const ROLE_NAMES_MAP: Record = { + 'bm-admin': 'ECOM_ADMIN', + 'bm-user': 'ECOM_USER', +}; + +export const ROLE_NAMES_MAP_REVERSE: Record = { + ECOM_ADMIN: 'bm-admin', + ECOM_USER: 'bm-user', +}; + +/** + * Maps the role name to an internal role ID accepted by the API. + */ +export function mapToInternalRole(role: string): string { + if (ROLE_NAMES_MAP[role]) { + return ROLE_NAMES_MAP[role]; + } + return role.toUpperCase().replace(/-/g, '_'); +} + +/** + * Maps the internal role ID to role name. + */ +export function mapFromInternalRole(roleID: string): string { + if (ROLE_NAMES_MAP_REVERSE[roleID]) { + return ROLE_NAMES_MAP_REVERSE[roleID]; + } + return roleID.toLowerCase().replace(/_/g, '-'); +} + +/** + * Configuration for creating Account Manager clients. + * Used for all Account Manager API clients (users, roles, orgs). + */ +export interface AccountManagerClientConfig { + /** + * Account Manager hostname. + * Defaults to: account.demandware.com + * + * @example "account.demandware.com" + */ + hostname?: string; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a typed Account Manager Users API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * @param config - Account Manager Users client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Account Manager Users client with OAuth auth + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerUsersClient({}, oauthStrategy); + * + * // List users + * const { data, error } = await client.GET('/dw/rest/v1/users', { + * params: { query: { pageable: { size: 25, page: 0 } } } + * }); + */ +export function createAccountManagerUsersClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerUsersClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${hostname}`, + }); + + // Core middleware: auth first + client.use(createAuthMiddleware(auth)); + + // Transform pageable query parameters from bracket notation to flattened format + // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y + client.use(createPageableTransformMiddleware()); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('am-users-api')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('AM-USERS')); + + return client; +} + +/** + * Retrieves details of a user by ID. + * + * @param client - Account Manager Users client + * @param userId - User ID (UUID) + * @param expand - Optional array of fields to expand (organizations, roles) + * @returns User details + * @throws Error if user is not found or request fails + */ +export async function getUser( + client: AccountManagerUsersClient, + userId: string, + expand?: UserExpandOption[], +): Promise { + const result = await client.GET('/dw/rest/v1/users/{userId}', { + params: { + path: {userId}, + query: expand && expand.length > 0 ? {expand} : undefined, + }, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + if (result.response?.status === 404) { + throw new Error(`User ${userId} not found`); + } + throw new Error(error.error?.message || `Failed to get user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Lists users with pagination. + * + * @param client - Account Manager Users client + * @param options - List options (size, page) + * @returns Paginated user collection + * @throws Error if request fails + */ +export async function listUsers( + client: AccountManagerUsersClient, + options: ListUsersOptions = {}, +): Promise { + const {size = 20, page = 0} = options; + + const result = await client.GET('/dw/rest/v1/users', { + params: { + query: { + pageable: { + size, + page, + }, + }, + }, + }); + + if (result.error) { + const error = result.error as { + error?: {message?: string}; + errors?: Array<{message?: string; code?: string}>; + }; + + // Check for pagination out-of-bounds error + const errorMessage = error.errors?.[0]?.message || error.error?.message; + if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { + throw new Error( + `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, + ); + } + + throw new Error(errorMessage || `Failed to list users: ${JSON.stringify(result.error)}`); + } + + return result.data || {content: []}; +} + +/** + * Creates a new user. + * + * @param client - Account Manager Users client + * @param user - User details + * @returns Created user + * @throws Error if request fails + */ +export async function createUser(client: AccountManagerUsersClient, user: UserCreate): Promise { + const result = await client.POST('/dw/rest/v1/users', { + body: user, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to create user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Updates an existing user. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @param changes - Changes to apply + * @returns Updated user + * @throws Error if request fails + */ +export async function updateUser( + client: AccountManagerUsersClient, + userId: string, + changes: UserUpdate, +): Promise { + const result = await client.PUT('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + body: changes, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to update user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Disables a user (soft delete - sets userState to DELETED). + * Users must be disabled before they can be purged. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @throws Error if request fails + */ +export async function deleteUser(client: AccountManagerUsersClient, userId: string): Promise { + const result = await client.POST('/dw/rest/v1/users/{userId}/disable', { + params: {path: {userId}}, + body: {}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to delete user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Purges a user (hard delete). + * Users must be in DELETED state before they can be purged. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @throws Error if request fails + */ +export async function purgeUser(client: AccountManagerUsersClient, userId: string): Promise { + const result = await client.DELETE('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to purge user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Resets a user to INITIAL state and sends activation instructions. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @throws Error if request fails + */ +export async function resetUser(client: AccountManagerUsersClient, userId: string): Promise { + const result = await client.POST('/dw/rest/v1/users/{userId}/reset', { + params: {path: {userId}}, + body: {}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to reset user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Helper to find a user by login (email) from a list of users. + * This is a convenience function since the API doesn't have a direct search by login endpoint. + * + * @param client - Account Manager Users client + * @param login - User login (email) + * @returns User if found, undefined otherwise + * @throws Error if request fails + */ +export async function findUserByLogin( + client: AccountManagerUsersClient, + login: string, + expand?: UserExpandOption[], +): Promise { + // Search through paginated results + let page = 0; + const pageSize = 100; + + while (true) { + const result = await client.GET('/dw/rest/v1/users', { + params: { + query: { + pageable: { + page, + size: pageSize, + }, + }, + }, + }); + + if (result.error) { + throw new Error(`Failed to search for user: ${JSON.stringify(result.error)}`); + } + + const users = result.data?.content || []; + const found = users.find((u) => u.mail === login); + + if (found) { + // If expand is requested, fetch the full user with expanded fields + if (expand && expand.length > 0 && found.id) { + return getUser(client, found.id, expand); + } + return found; + } + + // If we got fewer results than page size, we've reached the end + if (users.length < pageSize) { + return undefined; + } + + page++; + } +} + +// ============================================================================ +// Roles API +// ============================================================================ + +/** + * The typed Account Manager Roles client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createAccountManagerRolesClient} for instantiation + */ +export type AccountManagerRolesClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type AccountManagerRolesResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Account Manager Roles error response type from the generated schema. + */ +export type AccountManagerRolesError = RolesComponents['schemas']['ErrorResponse']; + +/** + * Role type from the generated schema. + */ +export type AccountManagerRole = RolesComponents['schemas']['Role']; +export type RoleCollection = RolesComponents['schemas']['RoleCollection']; + +/** + * Options for listing roles. + */ +export interface ListRolesOptions { + /** Page size (default: 20, min: 1, max: 4000) */ + size?: number; + /** Page number (default: 0) */ + page?: number; + /** Filter by target type (User or ApiClient) */ + roleTargetType?: 'ApiClient' | 'User'; +} + +/** + * Creates a typed Account Manager Roles API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * @param config - Account Manager Roles client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Account Manager Roles client with OAuth auth + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerRolesClient({}, oauthStrategy); + * + * // List roles + * const { data, error } = await client.GET('/dw/rest/v1/roles', { + * params: { query: { pageable: { size: 25, page: 0 } } } + * }); + */ +export function createAccountManagerRolesClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerRolesClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${hostname}`, + }); + + // Core middleware: auth first + client.use(createAuthMiddleware(auth)); + + // Transform pageable query parameters from bracket notation to flattened format + // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y + client.use(createPageableTransformMiddleware()); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('am-roles-api')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('AM-ROLES')); + + return client; +} + +/** + * Retrieves details of a role by ID. + * + * @param client - Account Manager Roles client + * @param roleId - Role ID + * @returns Role details + * @throws Error if role is not found or request fails + */ +export async function getRole(client: AccountManagerRolesClient, roleId: string): Promise { + const result = await client.GET('/dw/rest/v1/roles/{roleId}', { + params: {path: {roleId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + if (result.response?.status === 404) { + throw new Error(`Role ${roleId} not found`); + } + throw new Error(error.error?.message || `Failed to get role: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Lists roles with pagination. + * + * @param client - Account Manager Roles client + * @param options - List options (size, page, roleTargetType) + * @returns Paginated role collection + * @throws Error if request fails + */ +export async function listRoles( + client: AccountManagerRolesClient, + options: ListRolesOptions = {}, +): Promise { + const {size = 20, page = 0, roleTargetType} = options; + + const result = await client.GET('/dw/rest/v1/roles', { + params: { + query: { + pageable: { + size, + page, + }, + ...(roleTargetType && {roleTargetType}), + }, + }, + }); + + if (result.error) { + const error = result.error as { + error?: {message?: string}; + errors?: Array<{message?: string; code?: string}>; + }; + + // Check for pagination out-of-bounds error + const errorMessage = error.errors?.[0]?.message || error.error?.message; + if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { + throw new Error( + `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, + ); + } + + throw new Error(errorMessage || `Failed to list roles: ${JSON.stringify(result.error)}`); + } + + return result.data || {content: []}; +} + +// ============================================================================ +// Organizations API +// ============================================================================ + +/** + * Account Manager Organization type. + */ +export interface AccountManagerOrganization { + id: string; + name: string; + realms: string[]; + twoFARoles: string[]; + twoFAEnabled: boolean; + allowedVerifierTypes: string[]; + vaasEnabled: boolean; + sfIdentityFederation: boolean; + [key: string]: unknown; +} + +/** + * Account Manager Organization collection response. + */ +export interface OrganizationCollection { + content: AccountManagerOrganization[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + [key: string]: unknown; +} + +/** + * Account Manager audit log record. + */ +export interface AuditLogRecord { + timestamp: string; + authorDisplayName: string; + authorEmail?: string; + eventType: string; + eventMessage: string; + [key: string]: unknown; +} + +/** + * Audit log collection response. + */ +export interface AuditLogCollection { + content: AuditLogRecord[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + [key: string]: unknown; +} + +/** + * Options for listing organizations. + */ +export interface ListOrgsOptions { + /** Page size (default: 25, max: 5000) */ + size?: number; + /** Page number (0-based, default: 0) */ + page?: number; + /** Return all orgs (uses max page size of 5000) */ + all?: boolean; +} + +/** + * Account Manager Organizations API client. + */ +export interface AccountManagerOrgsClient { + /** + * Get organization by ID. + */ + getOrg(orgId: string): Promise; + + /** + * Get organization by name (searches for exact or partial match). + */ + getOrgByName(name: string): Promise; + + /** + * List organizations with pagination. + */ + listOrgs(options?: ListOrgsOptions): Promise; + + /** + * Get audit logs for an organization. + */ + getOrgAuditLogs(orgId: string): Promise; +} + +/** + * Transforms the API organization representation to an external format. + * Removes internal properties like 'links' that should not be exposed. + * + * @param org - The original organization object + * @returns The transformed organization object + */ +function toExternalOrg(org: AccountManagerOrganization): AccountManagerOrganization { + // Create a copy to avoid mutating the original + const transformed = {...org}; + // Always delete the links property + delete transformed.links; + return transformed; +} + +/** + * Creates an Account Manager Organizations API client. + * + * @param config - Account Manager Organizations client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Organizations API client + * + * @example + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerOrgsClient({}, oauthStrategy); + * + * // List organizations + * const orgs = await client.listOrgs({ size: 25, page: 0 }); + * + * // Get organization by ID + * const org = await client.getOrg('org-id'); + */ +export function createAccountManagerOrgsClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerOrgsClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const baseUrl = `https://${hostname}/dw/rest/v1`; + const logger = getLogger(); + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + // Get middleware from registry for am-orgs-api + const pluginMiddleware = registry.getMiddleware('am-orgs-api'); + + /** + * Applies middleware chain to a request. + * Adapts openapi-fetch middleware to work with fetch requests. + */ + async function applyMiddleware(request: Request): Promise { + let processedRequest = request; + + // Apply auth middleware (core) + if (auth.getAuthorizationHeader) { + const authHeader = await auth.getAuthorizationHeader(); + processedRequest.headers.set('Authorization', authHeader); + } + + // Apply plugin middleware from registry + for (const middleware of pluginMiddleware) { + if (middleware.onRequest) { + // Create minimal openapi-fetch context + const result = await middleware.onRequest({ + request: processedRequest, + schemaPath: '', + params: {}, + id: '', + options: { + baseUrl: baseUrl, + parseAs: 'json', + querySerializer: (params) => new URLSearchParams(params as Record).toString(), + bodySerializer: JSON.stringify, + fetch: fetch, + }, + }); + // Middleware can return Request or Response, but we only want Request here + if (result && result instanceof Request) { + processedRequest = result; + } + } + } + + // Apply logging middleware (last, so it sees all modifications) + logger.debug({method: processedRequest.method, url: processedRequest.url}, '[AM-ORGS] Making request'); + logger.trace( + { + method: processedRequest.method, + url: processedRequest.url, + headers: Object.fromEntries(processedRequest.headers.entries()), + }, + '[AM-ORGS] Request details', + ); + + return processedRequest; + } + + /** + * Applies middleware chain to a response. + */ + async function processResponse(request: Request, response: Response): Promise { + let processedResponse = response; + + // Apply plugin middleware from registry + for (const middleware of pluginMiddleware) { + if (middleware.onResponse) { + const result = await middleware.onResponse({ + request, + response: processedResponse, + schemaPath: '', + params: {}, + id: '', + options: { + baseUrl: baseUrl, + parseAs: 'json', + querySerializer: (params) => new URLSearchParams(params as Record).toString(), + bodySerializer: JSON.stringify, + fetch: fetch, + }, + }); + if (result) { + processedResponse = result; + } + } + } + + // Apply logging middleware (last) + logger.debug( + {method: request.method, url: request.url, status: processedResponse.status}, + '[AM-ORGS] Received response', + ); + + return processedResponse; + } + + /** + * Makes an authenticated request to the Account Manager API with middleware support. + */ + async function makeRequest(path: string, options: RequestInit = {}): Promise { + const url = `${baseUrl}${path}`; + let request = new Request(url, { + ...options, + headers: new Headers(options.headers), + }); + + // Apply middleware chain to request + request = await applyMiddleware(request); + + const response = await fetch(request); + + // Apply middleware chain to response + const processedResponse = await processResponse(request, response); + + // Handle errors + if (processedResponse.status === 401) { + throw new Error('Authentication invalid. Please (re-)authenticate.'); + } + if (processedResponse.status === 403) { + throw new Error('Operation forbidden. Please make sure you have the permission to perform this operation.'); + } + if (processedResponse.status >= 400) { + throw new Error(`Operation failed. Error code ${processedResponse.status}`); + } + + if (!processedResponse.ok) { + throw new Error(`Request failed: ${processedResponse.statusText}`); + } + + return processedResponse.json() as Promise; + } + + return { + async getOrg(orgId: string): Promise { + logger.debug({orgId}, '[AM-ORGS] Getting organization by ID'); + try { + const org = await makeRequest(`/organizations/${orgId}`); + return toExternalOrg(org); + } catch (error) { + if (error instanceof Error && error.message.includes('Error code 404')) { + throw new Error(`Organization ${orgId} not found`); + } + throw error; + } + }, + + async getOrgByName(name: string): Promise { + logger.debug({name}, '[AM-ORGS] Getting organization by name'); + const encodedName = encodeURIComponent(name); + let result: OrganizationCollection; + try { + result = await makeRequest( + `/organizations/search/findByName?startsWith=${encodedName}&ignoreCase=false`, + ); + } catch (error) { + if (error instanceof Error && error.message.includes('Error code 404')) { + throw new Error(`Organization ${name} not found`); + } + throw error; + } + + if (result.content.length === 0) { + throw new Error(`Organization ${name} not found`); + } + + if (result.content.length > 1) { + // Attempt to find exact match + const exactMatch = result.content.find((org) => org.name === name); + if (exactMatch) { + return toExternalOrg(exactMatch); + } + throw new Error(`Organization name "${name}" is ambiguous. Multiple organizations found.`); + } + + return toExternalOrg(result.content[0]); + }, + + async listOrgs(options: ListOrgsOptions = {}): Promise { + const {size = 25, page = 0, all = false} = options; + const pageSize = all ? 5000 : size; + + logger.debug({size: pageSize, page}, '[AM-ORGS] Listing organizations'); + + const result = await makeRequest(`/organizations?page=${page}&size=${pageSize}`); + + // Remove links from all organizations in the collection + return { + ...result, + content: result.content.map((org) => toExternalOrg(org)), + }; + }, + + async getOrgAuditLogs(orgId: string): Promise { + logger.debug({orgId}, '[AM-ORGS] Getting audit logs for organization'); + const logs = await makeRequest(`/organizations/${orgId}/audit-log-records`); + return logs; + }, + }; +} + +// ============================================================================ +// Shared Utilities +// ============================================================================ + +/** + * Middleware to transform pageable query parameters from bracket notation + * (pageable[size]=X&pageable[page]=Y) to flattened format (size=X&page=Y) + * that the Account Manager API expects. + */ +function createPageableTransformMiddleware(): Middleware { + const logger = getLogger(); + return { + async onRequest({request}) { + const url = new URL(request.url); + + // Check if URL has pageable[size] or pageable[page] parameters + const pageableSize = url.searchParams.get('pageable[size]'); + const pageablePage = url.searchParams.get('pageable[page]'); + + if (pageableSize !== null || pageablePage !== null) { + // Remove the bracket notation parameters + url.searchParams.delete('pageable[size]'); + url.searchParams.delete('pageable[page]'); + + // Add flattened parameters + if (pageableSize !== null) { + url.searchParams.set('size', pageableSize); + } + if (pageablePage !== null) { + url.searchParams.set('page', pageablePage); + } + + logger.trace( + { + originalUrl: request.url, + transformedUrl: url.toString(), + size: pageableSize, + page: pageablePage, + }, + '[AM] Transformed pageable query parameters from bracket to flattened notation', + ); + + return new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + }); + } + + return request; + }, + }; +} + +// ============================================================================ +// Unified Account Manager Client +// ============================================================================ +/** + * Unified Account Manager API client that combines users, roles, and organizations. + * + * This client provides direct access to all Account Manager API methods through + * a single interface, while internally using separate typed clients for type safety. + */ +export interface AccountManagerClient { + // Users API methods + /** Get user by ID */ + getUser(userId: string, expand?: UserExpandOption[]): Promise; + /** List users with pagination */ + listUsers(options?: ListUsersOptions): Promise; + /** Create a new user */ + createUser(user: UserCreate): Promise; + /** Update an existing user */ + updateUser(userId: string, changes: UserUpdate): Promise; + /** Disable a user (soft delete) */ + deleteUser(userId: string): Promise; + /** Purge a user (hard delete) */ + purgeUser(userId: string): Promise; + /** Reset a user to INITIAL state */ + resetUser(userId: string): Promise; + /** Find a user by login (email) */ + findUserByLogin(login: string, expand?: UserExpandOption[]): Promise; + /** Grant a role to a user, optionally with scope */ + grantRole(userId: string, role: string, scope?: string): Promise; + /** Revoke a role from a user, optionally removing specific scope */ + revokeRole(userId: string, role: string, scope?: string): Promise; + + // Roles API methods + /** Get role by ID */ + getRole(roleId: string): Promise; + /** List roles with pagination */ + listRoles(options?: ListRolesOptions): Promise; + + // Organizations API methods + /** Get organization by ID */ + getOrg(orgId: string): Promise; + /** Get organization by name */ + getOrgByName(name: string): Promise; + /** List organizations with pagination */ + listOrgs(options?: ListOrgsOptions): Promise; + /** Get audit logs for an organization */ + getOrgAuditLogs(orgId: string): Promise; +} + +/** + * Creates a unified Account Manager API client. + * + * This client provides direct access to all Account Manager API methods (users, roles, orgs) + * through a single interface, while internally using separate typed clients for type safety. + * + * @param config - Account Manager client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Unified Account Manager client + * + * @example + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerClient({}, oauthStrategy); + * + * // Users API + * const users = await client.listUsers({ size: 25, page: 0 }); + * const user = await client.getUser('user-id'); + * await client.createUser({ mail: 'user@example.com', ... }); + * + * // Roles API + * const roles = await client.listRoles({ size: 20, page: 0 }); + * const role = await client.getRole('bm-admin'); + * + * // Organizations API + * const orgs = await client.listOrgs({ size: 25, page: 0 }); + * const org = await client.getOrg('org-id'); + * const auditLogs = await client.getOrgAuditLogs('org-id'); + */ +export function createAccountManagerClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerClient { + // All three clients use the same config + + // Create internal clients (all use the same config, however, specifications are different) + const usersClient = createAccountManagerUsersClient(config, auth); + const rolesClient = createAccountManagerRolesClient(config, auth); + const orgsClient = createAccountManagerOrgsClient(config, auth); + + // Return unified client with all methods + return { + // Users API methods + getUser: (userId: string, expand?: UserExpandOption[]) => getUser(usersClient, userId, expand), + listUsers: (options?: ListUsersOptions) => listUsers(usersClient, options), + createUser: (user: UserCreate) => createUser(usersClient, user), + updateUser: (userId: string, changes: UserUpdate) => updateUser(usersClient, userId, changes), + deleteUser: (userId: string) => deleteUser(usersClient, userId), + purgeUser: (userId: string) => purgeUser(usersClient, userId), + resetUser: (userId: string) => resetUser(usersClient, userId), + findUserByLogin: (login: string, expand?: UserExpandOption[]) => findUserByLogin(usersClient, login, expand), + grantRole: (userId: string, role: string, scope?: string) => { + // Import grantRole from operations - it uses getUser internally which needs the client + return getUser(usersClient, userId).then((user) => { + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + const updatedRoles = currentRoles.includes(role) ? currentRoles : [...currentRoles, role]; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + if (scope) { + const scopes = scope.split(','); + // Parse existing filter + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [r, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(r, tenants.split(',')); + } + } + // Add new scopes + const existingScopes = filterMap.get(role) || []; + const allScopes = [...new Set([...existingScopes, ...scopes])]; + filterMap.set(role, allScopes); + // Rebuild filter string + roleTenantFilter = Array.from(filterMap.entries()) + .map(([r, tenants]) => `${r}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(usersClient, userId, { + roles: updatedRoles, + roleTenantFilter: roleTenantFilter || undefined, + }); + }); + }, + revokeRole: (userId: string, role: string, scope?: string) => { + // Import revokeRole logic - it uses getUser internally which needs the client + return getUser(usersClient, userId).then((user) => { + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + let updatedRoles = currentRoles; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + + if (!scope) { + // Remove entire role + updatedRoles = currentRoles.filter((r) => r !== role); + // Remove role from filter + const filters = roleTenantFilter.split(';').filter(Boolean); + roleTenantFilter = filters.filter((filter) => !filter.startsWith(`${role}:`)).join(';'); + } else { + // Remove specific scope + const scopes = scope.split(','); + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [r, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(r, tenants.split(',')); + } + } + const existingScopes = filterMap.get(role) || []; + const remainingScopes = existingScopes.filter((s) => !scopes.includes(s)); + if (remainingScopes.length === 0) { + // No scopes left, remove role entirely + updatedRoles = currentRoles.filter((r) => r !== role); + filterMap.delete(role); + } else { + filterMap.set(role, remainingScopes); + } + // Rebuild filter string + roleTenantFilter = Array.from(filterMap.entries()) + .map(([r, tenants]) => `${r}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(usersClient, userId, { + roles: updatedRoles, + roleTenantFilter: roleTenantFilter || undefined, + }); + }); + }, + + // Roles API methods + getRole: (roleId: string) => getRole(rolesClient, roleId), + listRoles: (options?: ListRolesOptions) => listRoles(rolesClient, options), + + // Organizations API methods + getOrg: (orgId: string) => orgsClient.getOrg(orgId), + getOrgByName: (name: string) => orgsClient.getOrgByName(name), + listOrgs: (options?: ListOrgsOptions) => orgsClient.listOrgs(options), + getOrgAuditLogs: (orgId: string) => orgsClient.getOrgAuditLogs(orgId), + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts b/packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts new file mode 100644 index 00000000..1fdff7df --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts @@ -0,0 +1,299 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/dw/rest/v1/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all roles. + * @description Retrieve a paginated list of all roles. Use the roleTargetType parameter to filter by target type. + */ + get: operations["getRoles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/roles/{roleId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get role by ID. + * @description Retrieve a specific role by ID. + */ + get: operations["getRole"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description A role defines permissions and access levels that can be assigned to Users and API Clients. */ + Role: { + /** @description Description of the role. */ + description?: string; + /** @description Enumeration name of the role. */ + roleEnumName?: string; + /** @description List of permissions granted by this role. */ + permissions?: string[]; + /** + * @description Scope level of the role (global or instance-specific). + * @enum {string} + */ + scope?: "GLOBAL" | "INSTANCE"; + /** + * @description Type of entity the role can be assigned to. + * @enum {string|null} + */ + targetType?: "ApiClient" | "User" | null; + /** @description Indicates if two-factor authentication is required for the role. */ + twoFAEnabled?: boolean; + /** @description Unique identifier of the role. */ + id?: string; + }; + /** @description A paginated collection of roles. */ + RoleCollection: { + content?: components["schemas"]["Role"][]; + }; + /** @description Pagination parameters for list operations. */ + Pageable: { + /** + * Format: int32 + * @description Zero-based page index. + */ + page?: number; + /** + * Format: int32 + * @description Number of items to return per page. + * @default 20 + */ + size: number; + }; + /** @description Standard error response format returned when API requests fail. */ + ErrorResponse: { + /** @description The list of errors */ + errors?: { + /** @description Error message */ + message?: string; + /** @description Error code */ + code?: string; + /** @description Field-specific errors */ + fieldErrors?: { + /** @description The field that contained the erroneous value */ + field?: string; + /** @description The value that was rejected */ + rejectedValue?: Record; + /** @description Whether this error was caused by failed binding (e.g. type mismatch) */ + bindingFailure?: boolean; + }[] | null; + }[]; + }; + }; + responses: { + /** @description Access token is missing or invalid */ + UnauthorizedError: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "errors": [ + * { + * "message": "Full authentication is required to access this resource", + * "code": "InsufficientAuthenticationException", + * "fieldErrors": null + * } + * ] + * } + */ + "application/json": { + /** @description The list of errors */ + errors?: { + /** @description Error message */ + message?: string; + /** @description Error code */ + code?: string; + /** @description Field-specific errors. */ + fieldErrors?: { + /** @description The field that contained the erroneous value. */ + field?: string; + /** @description The value that was rejected. */ + rejectedValue?: Record; + /** @description Whether this error was caused by failed binding (e.g. type mismatch). */ + bindingFailure?: boolean; + }[] | null; + }[]; + }; + }; + }; + /** @description Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. */ + RateLimitedError: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content?: never; + }; + }; + parameters: never; + requestBodies: never; + headers: { + /** @description Rate limit per minute. */ + "X-RateLimit-Limit": number; + /** @description The number of requests left in the current time window. */ + "X-RateLimit-Remaining": number; + /** @description The UTC timestamp at which the current rate limit window resets. */ + "X-RateLimit-Reset": string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getRoles: { + parameters: { + query?: { + pageable?: components["schemas"]["Pageable"]; + roleTargetType?: "ApiClient" | "User"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "content": [ + * { + * "description": "Business Manager User", + * "roleEnumName": "ECOM_USER", + * "permissions": [], + * "scope": "INSTANCE", + * "targetType": "User", + * "id": "bm-user" + * }, + * { + * "description": "Business Manager Administrator", + * "roleEnumName": "ECOM_ADMIN", + * "permissions": [], + * "scope": "INSTANCE", + * "targetType": "User", + * "id": "bm-admin" + * } + * ] + * } + */ + "application/json": components["schemas"]["RoleCollection"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + getRole: { + parameters: { + query?: never; + header?: never; + path: { + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "description": "Business Manager User", + * "roleEnumName": "ECOM_USER", + * "permissions": [], + * "scope": "INSTANCE", + * "targetType": "User", + * "id": "bm-user" + * } + */ + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A role with this ID was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts b/packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts new file mode 100644 index 00000000..96040c7d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts @@ -0,0 +1,892 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/dw/rest/v1/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all users. + * @description Retrieve a paginated list of all users. + */ + get: operations["getUsers"]; + put?: never; + /** + * Create user. + * @description Create a new user with the specified properties. + * Note: Users are created in INITIAL state. + */ + post: operations["createUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/users/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user by ID. + * @description Retrieve a specific user by ID. Use the expand parameter to retrieve more information on related organizations and roles. + */ + get: operations["getUser"]; + /** + * Update user. + * @description Apply a partial update to an existing user, i.e. an omitted field stays at its previous value, a contained field replaces the previously saved value. + */ + put: operations["updateUser"]; + post?: never; + /** + * Purge user. + * @description Purge a user. Only users in state Deleted may be purged. + */ + delete: operations["purgeUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/users/{userId}/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reset user to INITIAL status + * @description Reset a user to INITIAL status and send activation instructions. + */ + post: operations["resetUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/users/{userId}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Disable user. + * @description Disable a user, setting their userState to DELETED. This is a prerequisite for purging a user. + */ + post: operations["disableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description Request body for creating a new user. */ + UserCreate: { + /** @description Email address of the user. */ + mail: string; + /** @description First name of the user. */ + firstName: string; + /** @description Last name of the user. */ + lastName: string; + /** @description Display name of the user. */ + displayName?: string; + /** @description Business phone number. */ + businessPhone?: string | null; + /** @description Home phone number. */ + homePhone?: string | null; + /** @description Mobile phone number. */ + mobilePhone?: string | null; + /** + * @description Preferred locale for the user. + * @enum {string|null} + */ + preferredLocale?: "none" | "de" | "de_DE" | "en" | "en_CA" | "en_US" | "es" | "fr" | "fr_CA" | "nl" | null; + /** @description List of IDs of the roles this user possesses. */ + roles?: string[]; + /** @description List of organization IDs this user belongs to. */ + organizations: string[]; + /** @description Primary organization ID for the user. */ + primaryOrganization: string; + roleTenantFilter?: components["schemas"]["RoleTenantFilter"]; + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description Request body for updating an existing user. */ + UserUpdate: { + /** @description Email address of the user. */ + mail?: string; + /** @description First name of the user. */ + firstName?: string; + /** @description Last name of the user. */ + lastName?: string; + /** @description Display name of the user. */ + displayName?: string; + /** @description Business phone number. */ + businessPhone?: string | null; + /** @description Home phone number. */ + homePhone?: string | null; + /** @description Mobile phone number. */ + mobilePhone?: string | null; + /** + * @description Preferred locale for the user. + * @enum {string|null} + */ + preferredLocale?: "none" | "de" | "de_DE" | "en" | "en_CA" | "en_US" | "es" | "fr" | "fr_CA" | "nl" | null; + /** @description List of IDs of the roles this user possesses. */ + roles?: string[]; + /** @description List of organization IDs this user belongs to. */ + organizations?: string[]; + /** @description Primary organization ID for the user. */ + primaryOrganization?: string; + roleTenantFilter?: components["schemas"]["RoleTenantFilter"]; + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description User object returned in read operations. */ + UserRead: { + /** @description Email address of the user. */ + mail?: string; + /** @description First name of the user. */ + firstName?: string; + /** @description Last name of the user. */ + lastName?: string; + /** @description Display name of the user. */ + displayName?: string; + /** @description Business phone number. */ + businessPhone?: string | null; + /** @description Home phone number. */ + homePhone?: string | null; + /** @description Mobile phone number. */ + mobilePhone?: string | null; + /** + * @description Preferred locale for the user. + * @enum {string|null} + */ + preferredLocale?: "none" | "de" | "de_DE" | "en" | "en_CA" | "en_US" | "es" | "fr" | "fr_CA" | "nl" | null; + /** @description List of IDs of the roles this user possesses. */ + roles?: (string | components["schemas"]["Role"])[]; + /** @description List of organization IDs this user belongs to. */ + organizations?: (string | components["schemas"]["Organization"])[]; + /** @description Primary organization ID for the user. */ + primaryOrganization?: string; + roleTenantFilter?: components["schemas"]["RoleTenantFilter"]; + /** + * Format: int64 + * @description Timestamp when the password expires. + */ + readonly passwordExpirationTimestamp?: number | null; + /** + * Format: int64 + * @description Timestamp of the last password modification. + */ + readonly passwordModificationTimestamp?: number | null; + /** + * Format: date-time + * @description Timestamp when the user was created. + */ + readonly createdAt?: string | null; + /** + * Format: date-time + * @description Timestamp of the last modification. + */ + readonly lastModified?: string | null; + /** + * Format: date + * @description Date of the last successful login. + */ + readonly lastLoginDate?: string | null; + /** + * @description Current state of the user account. + * @enum {string} + */ + readonly userState?: "INITIAL" | "ENABLED" | "DELETED"; + /** + * Format: int64 + * @description Timestamp when the activation code was created. + */ + readonly activationCodeCreationTimestamp?: number | null; + /** @description Salesforce user identifier. */ + readonly sfUserId?: string | null; + /** @description List of authentication verifiers (e.g., MFA devices). */ + readonly verifiers?: components["schemas"]["Verifier"][]; + /** + * Format: int64 + * @description Timestamp when the user was marked for deletion. + */ + readonly deleteTimestamp?: number | null; + /** @description Map of role tenant filter assignments. */ + readonly roleTenantFilterMap?: Record; + /** @description Indicates if the user is linked to a Salesforce identity. */ + readonly linkedToSfIdentity?: boolean; + /** + * Format: uuid + * @description Unique identifier of the user. + */ + readonly id?: string; + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description A verifier represents a two-factor authentication device or method associated with a user account. */ + Verifier: { + /** @description Unique identifier of the verifier. */ + id: string; + /** @description Type of verifier (e.g., SMS, TOTP, WebAuthn). */ + type: string; + /** @description Human-readable name for the verifier. */ + displayName: string; + /** @description Current status of the verifier. */ + status: string; + }; + /** @description Request body for resetting a user to INITIAL state. */ + UserResetResource: { + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description Request body for disabling a user. */ + UserDeactivationResource: { + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description Pagination parameters for list operations. */ + Pageable: { + /** + * Format: int32 + * @description Zero-based page index. + */ + page?: number; + /** + * Format: int32 + * @description Number of items to return per page. + * @default 20 + */ + size: number; + }; + /** @description A paginated collection of users. */ + UserCollection: { + content?: components["schemas"]["UserRead"][]; + }; + /** @description A role defines permissions and access levels that can be assigned to Users and API Clients. */ + Role: { + /** @description Description of the role. */ + description?: string; + /** @description Enumeration name of the role. */ + roleEnumName?: string; + /** @description List of permissions granted by this role. */ + permissions?: string[]; + /** + * @description Scope level of the role (global or instance-specific). + * @enum {string} + */ + scope?: "GLOBAL" | "INSTANCE"; + /** + * @description Type of entity this role can be assigned to. + * @enum {string|null} + */ + targetType?: "ApiClient" | "User" | null; + /** @description Indicates if two-factor authentication is required for this role. */ + twoFAEnabled?: boolean; + /** @description Unique identifier of the role. */ + id?: string; + }; + /** @description An organization represents a customer, partner, or internal entity within the Account Manager system. */ + Organization: { + /** @description Name of the organization. */ + name?: string; + /** @description List of contact user IDs. */ + contactUsers?: string[]; + /** @description List of realm identifiers. */ + realms?: string[]; + /** @description Minimum password entropy requirement. */ + passwordMinEntropy?: number; + /** @description Number of previous passwords to remember. */ + passwordHistorySize?: number; + /** @description Number of days until password expires. */ + passwordDaysExpiration?: number; + /** @description Salesforce account identifiers. */ + sfAccountIds?: string[]; + /** + * @description Type of organization. + * @enum {string} + */ + type?: "CUSTOMER" | "PARTNER" | "INTERNAL"; + /** @description List of role IDs that require two-factor authentication. */ + twoFARoles?: string[]; + /** @description Indicates if two-factor authentication is enabled for the organization. */ + twoFAEnabled?: boolean; + /** @description Salesforce My Domain name. */ + sfMyDomain?: string | null; + /** @description Salesforce My Domain suffix. */ + sfMyDomainSuffix?: string; + /** @description Indicates if Salesforce My Domain is verified. */ + sfMyDomainVerified?: boolean; + /** + * Format: date-time + * @description Timestamp when Salesforce My Domain was verified. + */ + sfMyDomainVerificationTimestamp?: string | null; + /** + * @description Salesforce identity federation status. + * @enum {string} + */ + sfIdentityFederation?: "DISABLED" | "ENABLED"; + /** @description Indicates if just-in-time user provisioning is enabled. */ + justInTimeUserProvisioningEnabled?: boolean; + /** @description List of allowed verifier types for authentication. */ + allowedVerifierTypes?: string[]; + /** @description Indicates if inactive users should be automatically disabled. */ + disableInactiveUsers?: boolean; + /** @description Number of days before a user is considered inactive. */ + inactiveUserDays?: number; + /** @description Unique identifier of the organization. */ + readonly id?: string; + }; + /** @description Standard error response format returned when API requests fail. */ + ErrorResponse: { + /** @description The list of errors. */ + errors?: { + /** @description Error message. */ + message?: string; + /** @description Error code. */ + code?: string; + /** @description Field-specific errors. */ + fieldErrors?: { + /** @description List of error codes. */ + codes?: string[]; + /** @description Arguments for the error message. */ + arguments?: unknown; + /** @description Default error message. */ + defaultMessage?: string; + /** @description Name of the object that failed validation. */ + objectName?: string; + /** @description The field that contained the erroneous value. */ + field?: string; + /** @description The value that was rejected. */ + rejectedValue?: unknown; + /** @description Whether this error was caused by failed binding (e.g. type mismatch). */ + bindingFailure?: boolean; + /** @description Error code. */ + code?: string; + }[] | null; + }[]; + }; + /** + * @description Filter for role tenant assignments. Format: ROLE_ENUM_NAME:instance_id,instance_id;ROLE_ENUM_NAME:instance_id + * - Role enum names are separated by semicolons (;) + * - Each role enum name is followed by a colon (:) and its tenant filters + * - Tenant filters are comma-separated (,) + * - Each tenant filter consists of a 4-character realm and 3-character instance_id separated by underscore (_) + * - A special case is an instance_id ending in _sbx, as it gives access to all sandboxes of a realm + * + * Example: CC_USER:aabc_prd,aabc_t12;LOGCENTER_USER:aamn_sbx + */ + RoleTenantFilter: string; + }; + responses: { + /** @description Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. */ + RateLimitedError: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content?: never; + }; + }; + parameters: never; + requestBodies: never; + headers: { + /** @description Rate limit per minute. */ + "X-RateLimit-Limit": number; + /** @description The number of requests left in the current time window. */ + "X-RateLimit-Remaining": number; + /** @description The UTC timestamp at which the current rate limit window resets. */ + "X-RateLimit-Reset": string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getUsers: { + parameters: { + query?: { + pageable?: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "content": [ + * { + * "id": "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c", + * "mail": "alice.johnson@example.com", + * "firstName": "Alice", + * "lastName": "Johnson", + * "displayName": "Alice Johnson", + * "mobilePhone": "+1-555-9876", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd", + * "organizations": [ + * "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + * ], + * "primaryOrganization": "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b", + * "userState": "ENABLED", + * "createdAt": "2024-01-15T10:30:00Z", + * "lastModified": "2024-11-20T14:22:00Z", + * "lastLoginDate": "2024-12-03", + * "linkedToSfIdentity": false + * }, + * { + * "id": "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a", + * "mail": "robert.williams@example.com", + * "firstName": "Robert", + * "lastName": "Williams", + * "displayName": "Robert Williams", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:wxyz_stg", + * "organizations": [ + * "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + * ], + * "primaryOrganization": "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d", + * "userState": "INITIAL", + * "createdAt": "2024-12-04T09:15:00Z", + * "linkedToSfIdentity": false + * } + * ] + * } + */ + "application/json": components["schemas"]["UserCollection"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + createUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "mail": "john.doe@example.com", + * "firstName": "John", + * "lastName": "Doe", + * "primaryOrganization": "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6", + * "organizations": [ + * "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6" + * ], + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd" + * } + */ + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + /** @description URL to read created user */ + Location?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "id": "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a", + * "mail": "robert.williams@example.com", + * "firstName": "Robert", + * "lastName": "Williams", + * "displayName": "Robert Williams", + * "primaryOrganization": "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d", + * "organizations": [ + * "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + * ], + * "userState": "INITIAL", + * "createdAt": "2024-12-04T09:15:00Z", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd" + * } + */ + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + getUser: { + parameters: { + query?: { + /** + * @description Comma-separated list of fields that should be expanded in the response. Ensures that fully inlined organization and/or role objects get returned. + * @example organizations + */ + expand?: ("organizations" | "roles")[]; + }; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "id": "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c", + * "mail": "alice.johnson@example.com", + * "firstName": "Alice", + * "lastName": "Johnson", + * "displayName": "Alice Johnson", + * "mobilePhone": "+1-555-9876", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd", + * "organizations": [ + * "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + * ], + * "primaryOrganization": "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b", + * "userState": "ENABLED", + * "createdAt": "2024-01-15T10:30:00Z", + * "lastModified": "2024-11-20T14:22:00Z", + * "lastLoginDate": "2024-12-03", + * "linkedToSfIdentity": false + * } + */ + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A user with this ID was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "firstName": "Jane", + * "lastName": "Smith", + * "displayName": "Jane Smith", + * "mobilePhone": "+1-555-0123", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd" + * } + */ + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + purgeUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User has been purged. */ + 204: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A user with this ID was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Precondition Failed - User must be in status DELETED to be purged. */ + 412: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + resetUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserResetResource"]; + }; + }; + responses: { + /** @description User has been reset. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description User not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + disableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserDeactivationResource"]; + }; + }; + responses: { + /** @description User has been disabled. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description User not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 78fa9586..6bc6978f 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -204,6 +204,53 @@ export type { components as ScapiSchemasComponents, } from './scapi-schemas.js'; +export { + createAccountManagerClient, + createAccountManagerUsersClient, + getUser, + listUsers, + createUser, + updateUser, + deleteUser, + purgeUser, + resetUser, + findUserByLogin, + mapToInternalRole, + mapFromInternalRole, + ROLE_NAMES_MAP, + ROLE_NAMES_MAP_REVERSE, + createAccountManagerRolesClient, + getRole, + listRoles, + createAccountManagerOrgsClient, +} from './am-api.js'; +export type { + AccountManagerClient, + AccountManagerClientConfig, + AccountManagerUsersClient, + AccountManagerUser, + AccountManagerResponse, + AccountManagerError, + UserExpandOption, + UserCreate, + UserUpdate, + UserCollection, + UserState, + ListUsersOptions, + AccountManagerRolesClient, + AccountManagerRole, + AccountManagerRolesResponse, + AccountManagerRolesError, + RoleCollection, + ListRolesOptions, + AccountManagerOrgsClient, + AccountManagerOrganization, + OrganizationCollection, + AuditLogRecord, + AuditLogCollection, + ListOrgsOptions, +} from './am-api.js'; + export {createCdnZonesClient, CDN_ZONES_READ_SCOPES, CDN_ZONES_RW_SCOPES} from './cdn-zones.js'; export type { CdnZonesClient, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 9d22231b..e0e36cad 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -53,7 +53,10 @@ export type HttpClientType = | 'custom-apis' | 'scapi-schemas' | 'cdn-zones' - | 'webdav'; + | 'webdav' + | 'am-users-api' + | 'am-roles-api' + | 'am-orgs-api'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index dafdadd7..3855d79f 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -66,6 +66,9 @@ export { createSlasClient, createOdsClient, createCustomApisClient, + createAccountManagerUsersClient, + createAccountManagerRolesClient, + createAccountManagerOrgsClient, createCdnZonesClient, toOrganizationId, toTenantId, @@ -103,6 +106,28 @@ export type { CustomApisResponse, CustomApisPaths, CustomApisComponents, + AccountManagerUsersClient, + AccountManagerClientConfig, + AccountManagerUser, + AccountManagerResponse, + AccountManagerError, + UserCreate, + UserUpdate, + UserCollection, + UserState, + UserExpandOption, + AccountManagerRolesClient, + AccountManagerRole, + AccountManagerRolesResponse, + AccountManagerRolesError, + RoleCollection, + ListRolesOptions, + AccountManagerOrgsClient, + AccountManagerOrganization, + OrganizationCollection, + AuditLogRecord, + AuditLogCollection, + ListOrgsOptions, CdnZonesClient, CdnZonesClientConfig, CdnZonesClientOptions, @@ -204,6 +229,26 @@ export { SandboxNotFoundError, } from './operations/ods/index.js'; +// Operations - Users +export { + getUser, + getUserByLogin, + listUsers, + createUser, + updateUser, + deleteUser, + purgeUser, + resetUser, + grantRole, + revokeRole, +} from './operations/users/index.js'; + +// Operations - Roles +export {getRole, listRoles} from './operations/roles/index.js'; + +// Operations - Organizations +export {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from './operations/orgs/index.js'; + // Defaults export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST} from './defaults.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/orgs/index.ts b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts new file mode 100644 index 00000000..ff859289 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager organization management operations. + * + * This module provides high-level functions for managing organizations in Account Manager, + * including retrieving organization details and audit logs. + * + * ## Core Organization Functions + * + * - {@link getOrg} - Get organization details by ID + * - {@link getOrgByName} - Get organization details by name + * - {@link listOrgs} - List organizations with pagination + * - {@link getOrgAuditLogs} - Get audit logs for an organization + * + * ## Usage + * + * ```typescript + * import { + * getOrg, + * getOrgByName, + * listOrgs, + * getOrgAuditLogs, + * } from '@salesforce/b2c-tooling-sdk/operations/orgs'; + * import {createAccountManagerOrgsClient} from '@salesforce/b2c-tooling-sdk/clients'; + * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerOrgsClient({}, auth); + * + * // Get an organization by ID + * const org = await getOrg(client, 'org-id'); + * + * // Get an organization by name + * const org = await getOrgByName(client, 'My Organization'); + * + * // List organizations + * const orgs = await listOrgs(client, {size: 25, page: 0}); + * + * // Get audit logs + * const logs = await getOrgAuditLogs(client, 'org-id'); + * ``` + * + * ## Authentication + * + * Organization operations require OAuth authentication with appropriate Account Manager permissions. + * + * @module operations/orgs + */ +import type { + AccountManagerOrgsClient, + AccountManagerOrganization, + OrganizationCollection, + AuditLogCollection, + ListOrgsOptions, +} from '../../clients/am-api.js'; + +// Re-export types +export type { + AccountManagerOrganization, + OrganizationCollection, + AuditLogCollection, + ListOrgsOptions, +} from '../../clients/am-api.js'; + +/** + * Gets an organization by ID. + * + * @param client - Account Manager Organizations client + * @param orgId - Organization ID + * @returns Organization details + * @throws Error if organization is not found + */ +export async function getOrg(client: AccountManagerOrgsClient, orgId: string): Promise { + return client.getOrg(orgId); +} + +/** + * Gets an organization by name (searches for exact or partial match). + * + * @param client - Account Manager Organizations client + * @param name - Organization name + * @returns Organization details + * @throws Error if organization is not found or ambiguous + */ +export async function getOrgByName( + client: AccountManagerOrgsClient, + name: string, +): Promise { + return client.getOrgByName(name); +} + +/** + * Lists organizations with pagination. + * + * @param client - Account Manager Organizations client + * @param options - List options (size, page, all) + * @returns Organization collection + */ +export async function listOrgs( + client: AccountManagerOrgsClient, + options?: ListOrgsOptions, +): Promise { + return client.listOrgs(options); +} + +/** + * Gets audit logs for an organization. + * + * @param client - Account Manager Organizations client + * @param orgId - Organization ID + * @returns Audit log collection + */ +export async function getOrgAuditLogs(client: AccountManagerOrgsClient, orgId: string): Promise { + return client.getOrgAuditLogs(orgId); +} diff --git a/packages/b2c-tooling-sdk/src/operations/roles/index.ts b/packages/b2c-tooling-sdk/src/operations/roles/index.ts new file mode 100644 index 00000000..79e92ab2 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/roles/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager role management operations. + * + * This module provides high-level functions for retrieving role information + * in Account Manager, including listing roles and getting role details. + * + * ## Core Role Functions + * + * - {@link getRole} - Get role details by ID + * - {@link listRoles} - List roles with pagination + * + * ## Usage + * + * ```typescript + * import {getRole, listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; + * import {createAccountManagerRolesClient} from '@salesforce/b2c-tooling-sdk/clients'; + * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerRolesClient({}, auth); + * + * // Get a role by ID + * const role = await getRole(client, 'bm-admin'); + * + * // List roles + * const roles = await listRoles(client, {size: 25, page: 0}); + * + * // List roles filtered by target type + * const userRoles = await listRoles(client, {size: 25, page: 0, roleTargetType: 'User'}); + * ``` + * + * ## Authentication + * + * Role operations require OAuth authentication with appropriate Account Manager permissions. + * + * @module operations/roles + */ +export {getRole, listRoles} from '../../clients/am-api.js'; +export type {AccountManagerRole, RoleCollection, ListRolesOptions} from '../../clients/am-api.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/users/index.ts b/packages/b2c-tooling-sdk/src/operations/users/index.ts new file mode 100644 index 00000000..d70562ed --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/users/index.ts @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager user management operations. + * + * This module provides high-level functions for managing users in Account Manager, + * including CRUD operations, role management, and user lifecycle operations. + * + * ## Core User Functions + * + * - {@link getUser} - Get user details by ID + * - {@link getUserByLogin} - Get user details by login (email) + * - {@link listUsers} - List users with pagination + * - {@link createUser} - Create a new user + * - {@link updateUser} - Update an existing user + * - {@link deleteUser} - Disable a user (soft delete) + * - {@link purgeUser} - Purge a disabled user (hard delete) + * - {@link resetUser} - Reset a user to INITIAL state + * + * ## Usage + * + * ```typescript + * import { + * getUserByLogin, + * listUsers, + * createUser, + * grantRole, + * } from '@salesforce/b2c-tooling-sdk/operations/users'; + * import { createAccountManagerUsersClient } from '@salesforce/b2c-tooling-sdk/clients'; + * import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerUsersClient({}, auth); + * + * // Get a user by login + * const user = await getUserByLogin(client, 'user@example.com'); + * + * // List users + * const users = await listUsers(client, { size: 25, page: 0 }); + * + * // Create a new user + * const newUser = await createUser(client, { + * mail: 'newuser@example.com', + * firstName: 'John', + * lastName: 'Doe', + * organizations: ['org-id'], + * primaryOrganization: 'org-id', + * }); + * ``` + * + * ## Authentication + * + * User operations require OAuth authentication with appropriate Account Manager permissions. + * + * @module operations/users + */ +import type {AccountManagerUsersClient, AccountManagerUser, UserCreate, UserUpdate} from '../../clients/am-api.js'; +import { + getUser, + listUsers, + createUser as createUserApi, + updateUser as updateUserApi, + deleteUser, + purgeUser, + resetUser, + findUserByLogin, +} from '../../clients/am-api.js'; + +/** + * Options for creating a user. + */ +export interface CreateUserOptions { + /** User details */ + user: UserCreate; +} + +/** + * Options for updating a user. + */ +export interface UpdateUserOptions { + /** User ID */ + userId: string; + /** Changes to apply */ + changes: UserUpdate; +} + +/** + * Options for granting a role. + */ +export interface GrantRoleOptions { + /** User ID */ + userId: string; + /** Role to grant */ + role: string; + /** Optional scope for the role (tenant IDs, comma-separated) */ + scope?: string; +} + +/** + * Options for revoking a role. + */ +export interface RevokeRoleOptions { + /** User ID */ + userId: string; + /** Role to revoke */ + role: string; + /** Optional scope to remove (if not provided, removes entire role) */ + scope?: string; +} + +// Re-export primary API operations from client +export {getUser, listUsers, deleteUser, purgeUser, resetUser}; + +/** + * Retrieves details of a user by login (email). + * This searches through paginated results to find the user. + * + * @param client - Account Manager client + * @param login - User login (email) + * @returns User details + * @throws Error if user is not found + */ +export async function getUserByLogin(client: AccountManagerUsersClient, login: string): Promise { + const user = await findUserByLogin(client, login); + if (!user) { + throw new Error(`User ${login} not found`); + } + return user; +} + +/** + * Creates a new user. + * + * @param client - Account Manager client + * @param options - Create options (user details) + * @returns Created user + */ +export async function createUser( + client: AccountManagerUsersClient, + options: CreateUserOptions, +): Promise { + return createUserApi(client, options.user); +} + +/** + * Updates an existing user. + * + * @param client - Account Manager client + * @param options - Update options (userId, changes) + * @returns Updated user + */ +export async function updateUser( + client: AccountManagerUsersClient, + options: UpdateUserOptions, +): Promise { + return updateUserApi(client, options.userId, options.changes); +} + +/** + * Grants a role to a user, optionally with scope. + * This updates the user's roles and roleTenantFilter. + * + * @param client - Account Manager client + * @param options - Grant options (userId, role, optional scope) + * @returns Updated user + */ +export async function grantRole( + client: AccountManagerUsersClient, + options: GrantRoleOptions, +): Promise { + // First get the current user + const user = await getUser(client, options.userId); + + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + const updatedRoles = currentRoles.includes(options.role) ? currentRoles : [...currentRoles, options.role]; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + if (options.scope) { + const scopes = options.scope.split(','); + // Parse existing filter + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [role, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(role, tenants.split(',')); + } + } + // Add new scopes + const existingScopes = filterMap.get(options.role) || []; + const allScopes = [...new Set([...existingScopes, ...scopes])]; + filterMap.set(options.role, allScopes); + // Rebuild filter string + roleTenantFilter = Array.from(filterMap.entries()) + .map(([role, tenants]) => `${role}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(client, { + userId: options.userId, + changes: { + roles: updatedRoles, + roleTenantFilter: roleTenantFilter || undefined, + }, + }); +} + +/** + * Revokes a role from a user, optionally removing specific scope. + * + * @param client - Account Manager client + * @param options - Revoke options (userId, role, optional scope) + * @returns Updated user + */ +export async function revokeRole( + client: AccountManagerUsersClient, + options: RevokeRoleOptions, +): Promise { + // First get the current user + const user = await getUser(client, options.userId); + + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + let updatedRoles = currentRoles; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + + if (!options.scope) { + // Remove entire role + updatedRoles = currentRoles.filter((r) => r !== options.role); + // Remove all scopes for this role + const filters = roleTenantFilter.split(';').filter(Boolean); + roleTenantFilter = filters.filter((f) => !f.startsWith(`${options.role}:`)).join(';'); + } else { + // Remove specific scope + const scopes = options.scope.split(','); + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [role, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(role, tenants.split(',')); + } + } + const existingScopes = filterMap.get(options.role) || []; + const updatedScopes = existingScopes.filter((s) => !scopes.includes(s)); + if (updatedScopes.length > 0) { + filterMap.set(options.role, updatedScopes); + } else { + filterMap.delete(options.role); + } + roleTenantFilter = Array.from(filterMap.entries()) + .map(([role, tenants]) => `${role}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(client, { + userId: options.userId, + changes: { + roles: updatedRoles.length > 0 ? updatedRoles : undefined, + roleTenantFilter: roleTenantFilter || undefined, + }, + }); +} + +// Re-export types for convenience +export type {AccountManagerUser, UserCreate, UserUpdate, UserCollection} from '../../clients/am-api.js'; diff --git a/packages/b2c-tooling-sdk/test/cli/am-command.test.ts b/packages/b2c-tooling-sdk/test/cli/am-command.test.ts new file mode 100644 index 00000000..c940f96c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/am-command.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../helpers/stub-parse.js'; + +type TokenResponse = { + accessToken: string; + expires: Date; + scopes: string[]; +}; + +function futureDate(minutes: number): Date { + return new Date(Date.now() + minutes * 60 * 1000); +} + +// Create a test command class +class TestAmCommand extends AmCommand { + static id = 'test:am'; + static description = 'Test AM command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testAccountManagerClient() { + return this.accountManagerClient; + } + + public getDefaultAuthMethods() { + return super.getDefaultAuthMethods(); + } +} + +describe('cli/am-command', () => { + let config: Config; + let command: TestAmCommand; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + command = new TestAmCommand([], config); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('getDefaultAuthMethods', () => { + it('should get from parent and move implicit to first when present', () => { + const methods = command.getDefaultAuthMethods(); + // Parent returns ['client-credentials', 'implicit'] + // AmCommand should move 'implicit' to first: ['implicit', 'client-credentials'] + expect(methods).to.deep.equal(['implicit', 'client-credentials']); + expect(methods[0]).to.equal('implicit'); + expect(methods).to.include('client-credentials'); + }); + + it('should prepend implicit when not present in parent defaults', () => { + // This test verifies the logic works even if parent didn't include implicit + // In practice, parent always includes it, but we test the prepend logic + const parentMethods = ['client-credentials']; + // Simulate what would happen if parent didn't have implicit + const implicitIndex = parentMethods.indexOf('implicit'); + if (implicitIndex < 0) { + const result = ['implicit', ...parentMethods]; + expect(result).to.deep.equal(['implicit', 'client-credentials']); + expect(result[0]).to.equal('implicit'); + } + }); + }); + + describe('accountManagerClient', () => { + it('should create unified account manager client', async () => { + // Use implicit flow (AmCommand's default priority) with mocked implicitFlowLogin + stubParse(command, { + 'client-id': 'test-client', + }); + + await command.init(); + + // Mock getOAuthStrategy to return ImplicitOAuthStrategy with mocked implicitFlowLogin + const strategy = new ImplicitOAuthStrategy({ + clientId: 'test-client', + accountManagerHost: 'account.test.demandware.com', + }); + + // Mock implicitFlowLogin to avoid browser-based OAuth flow (following oauth-implicit.test.ts pattern) + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'test-token', + expires: futureDate(30), + scopes: [], + }); + + // Stub getOAuthStrategy to return our mocked strategy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinon.stub(command as any, 'getOAuthStrategy').returns(strategy); + + const client = command.testAccountManagerClient(); + + expect(client).to.exist; + // Unified client should have all API methods + expect(client.getUser).to.be.a('function'); + expect(client.listUsers).to.be.a('function'); + expect(client.getRole).to.be.a('function'); + expect(client.listRoles).to.be.a('function'); + expect(client.getOrg).to.be.a('function'); + expect(client.listOrgs).to.be.a('function'); + }); + + it('should use OAuth credentials from config', async () => { + // Use implicit flow (AmCommand's default priority) with mocked implicitFlowLogin + stubParse(command, { + 'client-id': 'test-client', + }); + + await command.init(); + + // Mock getOAuthStrategy to return ImplicitOAuthStrategy with mocked implicitFlowLogin + const strategy = new ImplicitOAuthStrategy({ + clientId: 'test-client', + accountManagerHost: 'account.test.demandware.com', + }); + + // Mock implicitFlowLogin to avoid browser-based OAuth flow (following oauth-implicit.test.ts pattern) + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'test-token', + expires: futureDate(30), + scopes: [], + }); + + // Stub getOAuthStrategy to return our mocked strategy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinon.stub(command as any, 'getOAuthStrategy').returns(strategy); + + const client = command.testAccountManagerClient(); + + expect(client).to.exist; + // Client should be created with OAuth authentication + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts new file mode 100644 index 00000000..c7f52389 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts @@ -0,0 +1,482 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createAccountManagerOrgsClient} from '../../src/clients/am-api.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('Account Manager Organizations API Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerOrgsClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerOrgsClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerOrgsClient({hostname: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + }); + + describe('getOrg', () => { + it('should get organization by ID', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1', 'realm2'], + twoFARoles: ['role1'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP'], + vaasEnabled: false, + sfIdentityFederation: true, + passwordMinEntropy: 8, + links: {self: {href: '/organizations/org-123'}}, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json(mockOrg); + }), + ); + + const org = await client.getOrg('org-123'); + + expect(org).to.not.have.property('links'); + expect(org.id).to.equal('org-123'); + expect(org.name).to.equal('Test Organization'); + expect(org.realms).to.deep.equal(['realm1', 'realm2']); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await client.getOrg('nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Organization nonexistent not found'); + } + }); + + it('should handle authentication errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json({error: {message: 'Unauthorized'}}, {status: 401}); + }), + ); + + try { + await client.getOrg('org-123'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Authentication invalid'); + } + }); + + it('should handle permission errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json({error: {message: 'Forbidden'}}, {status: 403}); + }), + ); + + try { + await client.getOrg('org-123'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Operation forbidden'); + } + }); + }); + + describe('getOrgByName', () => { + it('should get organization by exact name match', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-123'}}, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('startsWith')).to.equal('Test Organization'); + expect(url.searchParams.get('ignoreCase')).to.equal('false'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({content: [mockOrg]}); + }), + ); + + const org = await client.getOrgByName('Test Organization'); + + expect(org).to.not.have.property('links'); + expect(org.id).to.equal('org-123'); + expect(org.name).to.equal('Test Organization'); + }); + + it('should find exact match when multiple results returned', async () => { + const exactMatch = { + id: 'org-123', + name: 'Test Organization', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + const partialMatch = { + id: 'org-456', + name: 'Test Organization Extended', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [exactMatch, partialMatch]}); + }), + ); + + const org = await client.getOrgByName('Test Organization'); + + expect(org.id).to.equal('org-123'); + expect(org.name).to.equal('Test Organization'); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await client.getOrgByName('Nonexistent Org'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Organization Nonexistent Org not found'); + } + }); + + it('should throw error when organization name is ambiguous', async () => { + const org1 = { + id: 'org-1', + name: 'Test Org', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + const org2 = { + id: 'org-2', + name: 'Test Org Extended', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [org1, org2]}); + }), + ); + + try { + await client.getOrgByName('Ambiguous Name'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('is ambiguous'); + } + }); + + it('should handle 404 errors from API', async () => { + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await client.getOrgByName('Test Org'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Organization Test Org not found'); + } + }); + }); + + describe('listOrgs', () => { + it('should list organizations with default pagination', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-1'}}, + }, + { + id: 'org-2', + name: 'Organization 2', + realms: ['realm2'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-2'}}, + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('25'); + expect(url.searchParams.get('page')).to.equal('0'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs(); + + expect(result.content).to.have.lengthOf(2); + expect(result.content[0]).to.not.have.property('links'); + expect(result.content[1]).to.not.have.property('links'); + expect(result.content[0].id).to.equal('org-1'); + expect(result.content[1].id).to.equal('org-2'); + }); + + it('should list organizations with custom pagination', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }, + ], + totalElements: 50, + totalPages: 2, + number: 1, + size: 50, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('50'); + expect(url.searchParams.get('page')).to.equal('1'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs({size: 50, page: 1}); + + expect(result.content).to.have.lengthOf(1); + expect(result.number).to.equal(1); + expect(result.size).to.equal(50); + }); + + it('should use max page size when all flag is set', async () => { + const mockOrgs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 5000, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('5000'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs({all: true}); + + expect(result.size).to.equal(5000); + }); + + it('should remove links from all organizations in collection', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-1'}}, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs(); + + expect(result.content[0]).to.not.have.property('links'); + expect(result.content[0].id).to.equal('org-1'); + }); + }); + + describe('getOrgAuditLogs', () => { + it('should get audit logs for organization', async () => { + const mockLogs = { + content: [ + { + timestamp: '2025-01-15T10:30:45Z', + authorDisplayName: 'John Doe', + authorEmail: 'john.doe@example.com', + eventType: 'USER_CREATED', + eventMessage: 'User created successfully', + }, + { + timestamp: '2025-01-16T14:20:30Z', + authorDisplayName: 'Jane Smith', + authorEmail: 'jane.smith@example.com', + eventType: 'USER_UPDATED', + eventMessage: 'User updated', + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json(mockLogs); + }), + ); + + const result = await client.getOrgAuditLogs('org-123'); + + expect(result.content).to.have.lengthOf(2); + expect(result.content[0].eventType).to.equal('USER_CREATED'); + expect(result.content[0].authorDisplayName).to.equal('John Doe'); + expect(result.content[1].eventType).to.equal('USER_UPDATED'); + }); + + it('should handle empty audit logs', async () => { + const mockLogs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json(mockLogs); + }), + ); + + const result = await client.getOrgAuditLogs('org-123'); + + expect(result.content).to.have.lengthOf(0); + expect(result.totalElements).to.equal(0); + }); + + it('should handle authentication errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({error: {message: 'Unauthorized'}}, {status: 401}); + }), + ); + + try { + await client.getOrgAuditLogs('org-123'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Authentication invalid'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts new file mode 100644 index 00000000..af130817 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createAccountManagerRolesClient, getRole, listRoles} from '../../src/clients/am-api.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('Account Manager Roles API Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerRolesClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerRolesClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerRolesClient({hostname: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + }); + + describe('getRole', () => { + it('should get role by ID', async () => { + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }; + + server.use( + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const role = await getRole(client, 'bm-admin'); + + expect(role).to.deep.equal(mockRole); + expect(role.id).to.equal('bm-admin'); + }); + + it('should throw error when role not found', async () => { + server.use( + http.get(`${BASE_URL}/roles/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Role not found'}}, {status: 404}); + }), + ); + + try { + await getRole(client, 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Role nonexistent not found'); + } + }); + }); + + describe('listRoles', () => { + it('should list roles with pagination', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + }, + { + id: 'bm-user', + description: 'Business Manager User', + roleEnumName: 'ECOM_USER', + scope: 'INSTANCE', + targetType: 'User', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client, {size: 20, page: 0}); + + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].id).to.equal('bm-admin'); + }); + + it('should use default pagination', async () => { + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json({content: []}); + }), + ); + + const result = await listRoles(client); + + expect(result.content).to.be.an('array'); + }); + + it('should filter by target type', async () => { + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('roleTargetType')).to.equal('User'); + return HttpResponse.json({content: []}); + }), + ); + + const result = await listRoles(client, {roleTargetType: 'User'}); + + expect(result.content).to.be.an('array'); + }); + + it('should handle out-of-bounds page error', async () => { + server.use( + http.get(`${BASE_URL}/roles`, () => { + return HttpResponse.json( + { + errors: [{message: 'fromIndex(60) > toIndex(55)', code: 'PAGINATION_ERROR'}], + }, + {status: 400}, + ); + }), + ); + + try { + await listRoles(client, {size: 20, page: 3}); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Page 3 is out of bounds'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts new file mode 100644 index 00000000..8c23e93b --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import { + createAccountManagerUsersClient, + getUser, + listUsers, + createUser, + updateUser, + deleteUser, + resetUser, + findUserByLogin, + mapToInternalRole, + mapFromInternalRole, +} from '../../src/clients/am-api.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('Account Manager Users API Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerUsersClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerUsersClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerUsersClient({hostname: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + }); + + describe('getUser', () => { + it('should get user by ID', async () => { + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ENABLED', + }; + + server.use( + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + ); + + const user = await getUser(client, 'user-123'); + + expect(user).to.deep.equal(mockUser); + expect(user.mail).to.equal('user@example.com'); + }); + + it('should throw error when user not found', async () => { + server.use( + http.get(`${BASE_URL}/users/nonexistent`, () => { + return HttpResponse.json({error: {message: 'User not found'}}, {status: 404}); + }), + ); + + try { + await getUser(client, 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('User nonexistent not found'); + } + }); + }); + + describe('listUsers', () => { + it('should list users with pagination', async () => { + const mockUsers = { + content: [ + {id: 'user-1', mail: 'user1@example.com', firstName: 'John', lastName: 'Doe'}, + {id: 'user-2', mail: 'user2@example.com', firstName: 'Jane', lastName: 'Smith'}, + ], + }; + + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockUsers); + }), + ); + + const result = await listUsers(client, {size: 20, page: 0}); + + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].mail).to.equal('user1@example.com'); + }); + + it('should use default pagination', async () => { + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json({content: []}); + }), + ); + + const result = await listUsers(client); + + expect(result.content).to.be.an('array'); + }); + + it('should handle out-of-bounds page error', async () => { + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json( + { + errors: [{message: 'fromIndex(60) > toIndex(55)', code: 'PAGINATION_ERROR'}], + }, + {status: 400}, + ); + }), + ); + + try { + await listUsers(client, {size: 20, page: 3}); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Page 3 is out of bounds'); + } + }); + }); + + describe('createUser', () => { + it('should create a new user', async () => { + const newUser = { + mail: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + organizations: ['org-123'], + primaryOrganization: 'org-123', + }; + + const createdUser = { + id: 'user-456', + ...newUser, + userState: 'INITIAL', + }; + + server.use( + http.post(`${BASE_URL}/users`, async ({request}) => { + const body = await request.json(); + expect(body).to.deep.equal(newUser); + return HttpResponse.json(createdUser, {status: 201}); + }), + ); + + const result = await createUser(client, newUser); + + expect(result).to.deep.equal(createdUser); + expect(result.mail).to.equal('newuser@example.com'); + }); + }); + + describe('updateUser', () => { + it('should update an existing user', async () => { + const userId = 'user-123'; + const updates = { + firstName: 'Updated', + lastName: 'Name', + }; + + const updatedUser = { + id: userId, + mail: 'user@example.com', + ...updates, + }; + + server.use( + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = await request.json(); + expect(body).to.deep.equal(updates); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await updateUser(client, userId, updates); + + expect(result).to.deep.equal(updatedUser); + expect(result.firstName).to.equal('Updated'); + }); + }); + + describe('deleteUser', () => { + it('should delete a user', async () => { + const userId = 'user-123'; + + server.use( + http.post(`${BASE_URL}/users/${userId}/disable`, () => { + return HttpResponse.json({}, {status: 200}); + }), + ); + + await deleteUser(client, userId); + // Should not throw + }); + }); + + describe('resetUser', () => { + it('should reset a user', async () => { + const userId = 'user-123'; + + server.use( + http.post(`${BASE_URL}/users/${userId}/reset`, () => { + return HttpResponse.json({}, {status: 200}); + }), + ); + + await resetUser(client, userId); + // Should not throw + }); + }); + + describe('findUserByLogin', () => { + it('should find user by email', async () => { + const mockUsers = { + content: [{id: 'user-123', mail: 'user@example.com', firstName: 'John', lastName: 'Doe'}], + }; + + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + // findUserByLogin searches through paginated results + expect(url.searchParams.get('size')).to.equal('100'); + return HttpResponse.json(mockUsers); + }), + ); + + const user = await findUserByLogin(client, 'user@example.com'); + + expect(user).to.deep.equal(mockUsers.content[0]); + }); + + it('should return undefined when user not found', async () => { + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await findUserByLogin(client, 'nonexistent@example.com'); + + expect(result).to.be.undefined; + }); + + it('should search through multiple pages', async () => { + // Create 100 users for page 1 (full page size) + const page1Users = { + content: Array.from({length: 100}, (_, i) => ({ + id: `user-${i + 1}`, + mail: `user${i + 1}@example.com`, + })), + }; + + const page2Users = { + content: [{id: 'user-123', mail: 'user@example.com', firstName: 'John', lastName: 'Doe'}], + }; + + let callCount = 0; + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + callCount++; + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || '0'); + if (page === 0) { + return HttpResponse.json(page1Users); + } + return HttpResponse.json(page2Users); + }), + ); + + const user = await findUserByLogin(client, 'user@example.com'); + + expect(user).to.deep.equal(page2Users.content[0]); + expect(callCount).to.equal(2); + }); + }); + + describe('role mapping', () => { + it('should map role name to internal role ID', () => { + expect(mapToInternalRole('bm-admin')).to.equal('ECOM_ADMIN'); + expect(mapToInternalRole('bm-user')).to.equal('ECOM_USER'); + expect(mapToInternalRole('custom-role')).to.equal('CUSTOM_ROLE'); + }); + + it('should map internal role ID to role name', () => { + expect(mapFromInternalRole('bm-admin')).to.equal('bm-admin'); + expect(mapFromInternalRole('bm-user')).to.equal('bm-user'); + expect(mapFromInternalRole('CUSTOM_ROLE')).to.equal('custom-role'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts new file mode 100644 index 00000000..87501738 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from '../../../src/operations/orgs/index.js'; +import {createAccountManagerOrgsClient} from '../../../src/clients/am-api.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('operations/orgs', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerOrgsClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('getOrg', () => { + it('should get organization by ID', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1', 'realm2'], + twoFARoles: ['role1'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP'], + vaasEnabled: false, + sfIdentityFederation: true, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + ); + + const result = await getOrg(client, 'org-123'); + + expect(result).to.deep.equal(mockOrg); + expect(result.id).to.equal('org-123'); + expect(result.name).to.equal('Test Organization'); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await getOrg(client, 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); + + describe('getOrgByName', () => { + it('should get organization by name', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [mockOrg]}); + }), + ); + + const result = await getOrgByName(client, 'Test Organization'); + + expect(result).to.deep.equal(mockOrg); + expect(result.name).to.equal('Test Organization'); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await getOrgByName(client, 'Nonexistent Org'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); + + describe('listOrgs', () => { + it('should list organizations with default options', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('25'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await listOrgs(client); + + expect(result).to.deep.equal(mockOrgs); + }); + + it('should list organizations with pagination', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }, + ], + totalElements: 50, + totalPages: 2, + number: 1, + size: 50, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('50'); + expect(url.searchParams.get('page')).to.equal('1'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await listOrgs(client, {size: 50, page: 1}); + + expect(result).to.deep.equal(mockOrgs); + }); + + it('should list all organizations when all flag is set', async () => { + const mockOrgs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 5000, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('5000'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await listOrgs(client, {all: true}); + + expect(result.size).to.equal(5000); + }); + }); + + describe('getOrgAuditLogs', () => { + it('should get audit logs for organization', async () => { + const mockLogs = { + content: [ + { + timestamp: '2025-01-15T10:30:45Z', + authorDisplayName: 'John Doe', + authorEmail: 'john.doe@example.com', + eventType: 'USER_CREATED', + eventMessage: 'User created successfully', + }, + { + timestamp: '2025-01-16T14:20:30Z', + authorDisplayName: 'Jane Smith', + authorEmail: 'jane.smith@example.com', + eventType: 'USER_UPDATED', + eventMessage: 'User updated', + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json(mockLogs); + }), + ); + + const result = await getOrgAuditLogs(client, 'org-123'); + + expect(result).to.deep.equal(mockLogs); + expect(result.content).to.have.lengthOf(2); + expect(result.content[0].eventType).to.equal('USER_CREATED'); + }); + + it('should handle empty audit logs', async () => { + const mockLogs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json(mockLogs); + }), + ); + + const result = await getOrgAuditLogs(client, 'org-123'); + + expect(result.content).to.have.lengthOf(0); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts new file mode 100644 index 00000000..54494541 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {getRole, listRoles} from '../../../src/operations/roles/index.js'; +import {createAccountManagerRolesClient} from '../../../src/clients/am-api.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('operations/roles', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerRolesClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('getRole', () => { + it('should get role by ID', async () => { + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }; + + server.use( + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const result = await getRole(client, 'bm-admin'); + + expect(result).to.deep.equal(mockRole); + }); + }); + + describe('listRoles', () => { + it('should list roles with default options', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client); + + expect(result).to.deep.equal(mockRoles); + }); + + it('should list roles with pagination', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('50'); + expect(url.searchParams.get('page')).to.equal('1'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client, {size: 50, page: 1}); + + expect(result).to.deep.equal(mockRoles); + }); + + it('should list roles with target type filter', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + targetType: 'User', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('roleTargetType')).to.equal('User'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client, {roleTargetType: 'User'}); + + expect(result).to.deep.equal(mockRoles); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/users/index.test.ts b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts new file mode 100644 index 00000000..9510cab2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {getUserByLogin, createUser, updateUser, grantRole, revokeRole} from '../../../src/operations/users/index.js'; +import {createAccountManagerUsersClient} from '../../../src/clients/am-api.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('operations/users', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerUsersClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('getUserByLogin', () => { + it('should get user by login', async () => { + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await getUserByLogin(client, 'user@example.com'); + + expect(result).to.deep.equal(mockUser); + }); + + it('should throw error when user not found', async () => { + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await getUserByLogin(client, 'nonexistent@example.com'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('User nonexistent@example.com not found'); + } + }); + }); + + describe('createUser', () => { + it('should create a user', async () => { + const userData = { + mail: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + organizations: ['org-123'], + primaryOrganization: 'org-123', + }; + + const createdUser = { + id: 'user-456', + ...userData, + }; + + server.use( + http.post(`${BASE_URL}/users`, async ({request}) => { + const body = (await request.json()) as typeof userData; + expect(body).to.deep.equal(userData); + return HttpResponse.json(createdUser, {status: 201}); + }), + ); + + const result = await createUser(client, {user: userData}); + + expect(result).to.deep.equal(createdUser); + }); + }); + + describe('updateUser', () => { + it('should update a user', async () => { + const userId = 'user-123'; + const changes = { + firstName: 'Updated', + lastName: 'Name', + }; + + const updatedUser = { + id: userId, + mail: 'user@example.com', + ...changes, + }; + + server.use( + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as typeof changes; + expect(body).to.deep.equal(changes); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await updateUser(client, {userId, changes}); + + expect(result).to.deep.equal(updatedUser); + }); + }); + + describe('grantRole', () => { + it('should grant a role without scope', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: [], + }; + + const updatedUser = { + ...mockUser, + roles: ['bm-admin'], + }; + + let getUserCallCount = 0; + let updateUserCallCount = 0; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + getUserCallCount++; + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + updateUserCallCount++; + const body = (await request.json()) as {roles?: string[]}; + expect(body.roles).to.include('bm-admin'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await grantRole(client, {userId, role: 'bm-admin'}); + + expect(result).to.deep.equal(updatedUser); + expect(getUserCallCount).to.equal(1); + expect(updateUserCallCount).to.equal(1); + }); + + it('should grant a role with scope', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: [], + roleTenantFilter: '', + }; + + const updatedUser = { + ...mockUser, + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('bm-admin:tenant1,tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await grantRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant1,tenant2', + }); + + expect(result).to.deep.equal(updatedUser); + }); + + it('should add scope to existing role', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1', + }; + + const updatedUser = { + ...mockUser, + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('tenant1'); + expect(body.roleTenantFilter).to.include('tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + await grantRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant2', + }); + }); + }); + + describe('revokeRole', () => { + it('should revoke entire role', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin', 'bm-user'], + roleTenantFilter: 'bm-admin:tenant1', + }; + + const updatedUser = { + ...mockUser, + roles: ['bm-user'], + roleTenantFilter: '', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roles?: string[]}; + expect(body.roles).to.not.include('bm-admin'); + expect(body.roles).to.include('bm-user'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await revokeRole(client, {userId, role: 'bm-admin'}); + + expect(result).to.deep.equal(updatedUser); + }); + + it('should revoke specific scope from role', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + const updatedUser = { + ...mockUser, + roleTenantFilter: 'bm-admin:tenant1', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('tenant1'); + expect(body.roleTenantFilter).to.not.include('tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + await revokeRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant2', + }); + }); + + it('should remove role when all scopes are revoked', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1', + }; + + const updatedUser = { + ...mockUser, + roles: [], + roleTenantFilter: '', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.be.undefined; + return HttpResponse.json(updatedUser); + }), + ); + + await revokeRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant1', + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af0e28e8..7a0e99d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: mocha: specifier: ^10 version: 10.8.2 + msw: + specifier: ^2.0.0 + version: 2.12.4(@types/node@18.19.130)(typescript@5.9.3) oclif: specifier: ^4 version: 4.22.44(@types/node@18.19.130)