diff --git a/api/docs/space-api-docs.yaml b/api/docs/space-api-docs.yaml
index c5d7747..778d055 100644
--- a/api/docs/space-api-docs.yaml
+++ b/api/docs/space-api-docs.yaml
@@ -1,65 +1,121 @@
openapi: 3.0.4
+
info:
title: SPACE API
- description: |-
- SPACE (Subscription and Pricing Access Control Engine) is the reference implementation of **ASTRA**, the architecture presented in the ICSOC ’25 paper *“iSubscription: Bridging the Gap Between Contracts and Runtime Access Control in SaaS.”* The API lets you:
-
- * Manage pricing/s of your SaaS (_iPricings_).
- * Store and novate contracts (_iSubscriptions_).
- * Enforce subscription compliance at run time through pricing-driven self-adaptation.
-
- ---
- ### Authentication & roles
-
- Every request must include an API key in the `x-api-key` header, except **/users/authentication**, which is used to obtain the API Key through the user credentials.
- Each key is bound to **one role**, which determines the operations you can perform:
-
- | Role | Effective permissions | Practical scope in this API |
- | ---- | -------------------- | --------------------------- |
- | **ADMIN** | `allowAll = true` | Unrestricted access to every tag and HTTP verb |
- | **MANAGER** | Blocks **DELETE** on any resource | Full read/write except destructive operations |
- | **EVALUATOR** | `GET` on `services`, `features`
`POST` on `features` | Read-only configuration plus feature evaluation |
-
- *(These rules cannot be declared natively in OAS 3.0; but SPACE enforces them at run time.)*
-
- ---
- ### Example data
-
- [Zoom](https://www.zoom.com/) is used throughout the specification as a running example; replace it with your own services and pricings when integrating SPACE into your product.
-
- ---
- See the external documentation links for full details on iPricing, iSubscription, and ASTRA’s optimistic-update algorithm.
+ description: |
+ # SPACE - Subscription and Pricing Access Control Engine
+
+ SPACE is a pricing-driven self-adaptation microservice that leverages the iPricing and iSubscription metamodels to enforce feature-level access control in accordance with the terms of active subscription contracts.
+
+ The API enables you to:
+ - **Manage pricing for multiple services**
+ - **Store and manage contracts**
+ - **Enforce subscription compliance**
+
+ ## Authentication & API Keys
+
+ SPACE supports two types of API keys, each serving a different purpose:
+
+ ### User API Keys
+ - **Purpose**: Manage user accounts, organizations, services and pricing from the SPACE UI.
+ - **Obtaining**: Authenticate via `POST /users/authenticate` endpoint with username and password
+ - **Usage**: Include in `x-api-key` header for requests
+ - **Accessible Roles**: `ADMIN` (full access), `USER` (access limited to own account and organizations)
+ - **Access Pattern**: Can access `/users/**` and `/organizations/**` routes
+ - **Example Use Cases**:
+ - Creating services
+ - Managing organizations and their members
+ - Viewing analytics
+
+ ### Organization API Keys
+ - **Purpose**: Perform programmatic operations within an organization's context
+ - **Obtaining**: Created by organization owners/admins/managers via `POST /organizations/:organizationId/api-keys`
+ - **Usage**: Include in `x-api-key` header for requests
+ - **Accessible Scopes**:
+ - `ALL`: Full access to organization resources and management operations
+ - `MANAGEMENT`: Full access to organization resources and limited management operations
+ - `EVALUATION`: Read-only access to services/pricings and feature evaluation
+ - **Access Pattern**: Can access `/services/**`, `/contracts/**`, `/features/**` routes
+ - **Example Use Cases**:
+ - Programmatically manage services and pricings
+ - Evaluate access to features
+ - Manage contracts
+
+ ## Organization Roles
+
+ Users within an organization can have the following roles:
+
+ | Role | Permissions |
+ |---------------|-------------|
+ | **OWNER** | Full control: add/remove members, manage API keys, update organization, transfer ownership |
+ | **ADMIN** | Nearly full control except cannot transfer ownership |
+ | **MANAGER** | Can manage members and services, limited API key operations (cannot perform operations on ALL-scoped API Keys) |
+ | **EVALUATOR** | Read-only access to services and feature evaluation, can only remove themselves |
+
+ ## Permission Summary by Endpoint Category
+
+ All endpoints require an `x-api-key` header unless explicitly marked as **Public**.
+
+ - **Public**: No authentication required
+ - **User Key (ADMIN)**: Requires User API Key with ADMIN role
+ - **User Key (USER)**: Requires User API Key with USER role (or ADMIN)
+ - **Org Key (scope)**: Requires Organization API Key with specified scope
+ - **Org Members (role)**: Requires User API key. If the key does not have ADMIN role, the user must be a member of the organization and have –at least– the specified role.
+
contact:
email: agarcia29@us.es
- version: 1.0.0
+ name: SPACE Support
+ version: 2.0.0
license:
name: MIT License
- url: https://opensource.org/license/mit
+ url: https://opensource.org/licenses/MIT
+
externalDocs:
- description: Find out more about Pricing-driven Solutions
+ description: "SPHERE: SaaS Pricing Holistic Evaluation and Regulation Environment"
url: https://sphere.score.us.es/
-servers: []
+
+servers:
+ # - url: 'https://api.space.es/api/v1'
+ # description: Production
+ - url: 'http://localhost:3000/api/v1'
+ description: Development (local)
+
tags:
- - name: authentication
- description: Endpoint to get API Key (required to perform other requests) from user credentials.
- - name: users
- description: Operations about users. Mainly to get credentials, API keys, etc.
- - name: services
- description: Configure the services that your SaaS is going to be offering.
- - name: contracts
- description: >-
- Everything about your users contracts. In this version this will store
- users' iSubscriptions.
- - name: features
- description: Endpoints to perform evaluations once system is configured.
- - name: analytics
- description: Endpoints to retrieve information about the usage of SPACE.
+ - name: Authentication
+ description: User authentication and API key management
+ - name: Users
+ description: User account management (User API Key only)
+ - name: Organizations
+ description: Organization management (User API Key only)
+ - name: Services
+ description: Service and pricing management (both API key types)
+ - name: Contracts
+ description: Subscription contract management (both API key types)
+ - name: Features
+ description: Feature evaluation and toggle management (Org API Key only)
+ - name: Analytics
+ description: System usage analytics (User ADMIN or Org Key with MANAGEMENT)
+ - name: Cache
+ description: Cache management (User ADMIN only)
+ - name: Events
+ description: WebSocket event management (Public)
+ - name: Healthcheck
+ description: Service health verification (Public)
+
paths:
/users/authenticate:
post:
summary: Authenticate user and obtain API Key
- tags:
- - authentication
+ description: |
+ Authenticates a user with username and password and returns a User API Key
+ that can be used for subsequent authenticated requests.
+
+ **Authentication**: Public (no API key required)
+
+ **Returns**: User information along with User API Key
+
+ tags:
+ - Authentication
requestBody:
required: true
content:
@@ -99,22 +155,141 @@ paths:
error: Invalid credentials
'422':
$ref: '#/components/responses/UnprocessableEntity'
+
/users:
get:
summary: Get all users
- tags:
- - users
+ description: |
+ Retrieves a paginated list of users, with optional filtering by username and search offset/limit.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Any authenticated user can search for users by providing the `q` parameter (autocomplete mode)
+ - Only ADMIN users can list all users without a search query (list all mode)
+
+ **Two modes of operation**:
+
+ 1. **Search Mode** (when `q` parameter is provided):
+ - Searches for users whose usernames match (case-insensitive regex)
+ - Returns a paginated list of matching users
+ - Optimized for autocomplete and user lookup
+ - `offset` and `limit` apply to search results, not the full user database
+
+ 2. **List All Mode** (when no `q` parameter or empty string):
+ - Returns all users in the system with pagination
+ - Use `offset` and `limit` to paginate through all users
+ - Requires ADMIN role
+
+ tags:
+ - Users
security:
- ApiKeyAuth: []
+ parameters:
+ - name: q
+ in: query
+ required: false
+ schema:
+ type: string
+ description: |
+ Search query to match against usernames (case-insensitive regex).
+ - If provided and not empty: performs search filtering
+ - If not provided or empty: returns all users (ADMIN required)
+ example: john
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 50
+ default: 10
+ description: Maximum number of results to return per page
+ example: 20
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ description: Number of records to skip (for pagination)
+ example: 0
responses:
'200':
- description: Operation Completed
+ description: Paginated list of users (with metadata)
content:
application/json:
schema:
- type: array
- items:
- $ref: '#/components/schemas/User'
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/User'
+ description: Array of user objects
+ pagination:
+ type: object
+ properties:
+ offset:
+ type: integer
+ description: Current offset value
+ example: 0
+ limit:
+ type: integer
+ description: Current limit value
+ example: 10
+ total:
+ type: integer
+ description: Total number of users (filtered by search query if provided)
+ example: 42
+ page:
+ type: integer
+ description: Current page number (1-indexed)
+ example: 1
+ pages:
+ type: integer
+ description: Total number of pages
+ example: 5
+ examples:
+ searchResults:
+ summary: Search results for username matching "john"
+ value:
+ data:
+ - username: john_doe
+ role: USER
+ apiKey: usr_abc123
+ - username: johnny_test
+ role: USER
+ apiKey: usr_def456
+ pagination:
+ offset: 0
+ limit: 10
+ total: 2
+ page: 1
+ pages: 1
+ listAll:
+ summary: List of all users (page 1)
+ value:
+ data:
+ - username: admin_user
+ role: ADMIN
+ apiKey: usr_ghi789
+ - username: john_doe
+ role: USER
+ apiKey: usr_abc123
+ pagination:
+ offset: 0
+ limit: 2
+ total: 42
+ page: 1
+ pages: 21
+ '400':
+ description: Bad request - invalid parameters
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
'401':
description: Authentication required
'403':
@@ -125,10 +300,22 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+
post:
summary: Create a new user
+ description: |
+ Creates a new user account with the specified credentials and role.
+
+ **Authentication**: Public (with restrictions) or User API Key
+
+ **Permission**:
+
+ - Anyone can create a new USER account (default role)
+ - Only ADMIN users can create ADMIN accounts. Provide `x-api-key` with ADMIN role to create an ADMIN user.
+ - A default organization is automatically created for the new user
+
tags:
- - users
+ - Users
security:
- ApiKeyAuth: []
requestBody:
@@ -144,8 +331,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/User'
- '401':
- description: Authentication required
+ '400':
+ description: Invalid user data or username already exists
'403':
description: Insufficient permissions
'404':
@@ -158,15 +345,54 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+
+ /users/me:
+ get:
+ summary: Get current user details
+ description: |
+ Retrieves detailed information about the user whose API key is used.
+
+ **Authentication**: User API Key
+
+ **Permission**: Any authenticated user can access their own details using this endpoint, regardless of role. This is the recommended endpoint for users to retrieve their own information without needing ADMIN privileges.
+
+ tags:
+ - Users
+ security:
+ - ApiKeyAuth: []
+ responses:
+ '200':
+ description: Current user details
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ username:
+ $ref: '#/components/schemas/Username'
+ role:
+ $ref: '#/components/schemas/Role'
+ '401':
+ description: Unauthorized or missing API key
+
/users/{username}:
- parameters:
- - $ref: '#/components/parameters/Username'
get:
summary: Get user by username
+ description: |
+ Retrieves detailed information about a specific user
+
+ **Authentication**: User API Key
+
+ **Required Role**: USER
+
+ **Permission**: Only ADMIN users can view any user's details. Otherwise, users can only view their own details.
+
tags:
- - users
+ - Users
security:
- ApiKeyAuth: []
+ parameters:
+ - $ref: '#/components/parameters/Username'
responses:
'200':
description: Operation Completed
@@ -186,12 +412,24 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+
put:
summary: Update user
+ description: |
+ Updates a user's information including username, password, and role.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Users can update their own account (username, password)
+ - Only ADMIN can update other users or modify roles to ADMIN
+
tags:
- - users
+ - Users
security:
- ApiKeyAuth: []
+ parameters:
+ - $ref: '#/components/parameters/Username'
requestBody:
required: true
content:
@@ -205,8 +443,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/User'
- '401':
- description: Authentication required
+ '400':
+ description: Invalid update data
'403':
description: Insufficient permissions
'404':
@@ -219,12 +457,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+
delete:
summary: Delete user
+ description: |
+ Deletes a user account and handles organization ownership transfer/deletion.
+
+ **Authentication**: User API Key
+
+ **Permission**: Only ADMIN users can delete other accounts
+
+ **Cascading Actions**:
+ - User is removed from all organization member lists
+ - Organizations owned by user are transferred to eligible members (by role priority: ADMIN > MANAGER > EVALUATOR)
+ - Organizations without eligible members are deleted
+ - Default organizations (created during signup) are deleted regardless
+ - User's API keys are invalidated
+
+ **Constraints**:
+ - Cannot delete the last ADMIN user in the system
+
tags:
- - users
+ - Users
security:
- ApiKeyAuth: []
+ parameters:
+ - $ref: '#/components/parameters/Username'
responses:
'204':
description: User deleted
@@ -240,11 +498,20 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+
/users/{username}/api-key:
put:
summary: Regenerate user's API Key
+ description: |
+ Generates a new User API Key for the specified user. This immediately invalidates
+ any previous API key for that user.
+
+ **Authentication**: User API Key
+
+ **Permission**: Only ADMIN users can regenerate API keys for other users
+
tags:
- - users
+ - Users
security:
- ApiKeyAuth: []
parameters:
@@ -271,11 +538,25 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+
/users/{username}/role:
put:
summary: Change user's role
+ description: |
+ Changes a user's role between ADMIN and USER.
+
+ **Authentication**: User API Key
+
+ **Required Role**: ADMIN
+
+ **Permission**: Only ADMIN users can change roles
+
+ **Constraints**:
+ - Cannot demote the last ADMIN user in the system
+ - Source and target roles must be different
+
tags:
- - users
+ - Users
security:
- ApiKeyAuth: []
parameters:
@@ -286,6 +567,8 @@ paths:
application/json:
schema:
type: object
+ required:
+ - role
properties:
role:
$ref: '#/components/schemas/Role'
@@ -297,9 +580,7 @@ paths:
schema:
$ref: '#/components/schemas/User'
'400':
- description: Invalid role
- '401':
- description: Authentication required
+ description: Invalid role value
'403':
description: Insufficient permissions
'404':
@@ -312,163 +593,331 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
- /services:
+
+ /organizations:
get:
+ summary: List organizations with optional filtering and pagination
+ description: |
+ Retrieves a list of organizations accessible to the authenticated user.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - ADMIN users: can see all organizations with pagination and filtering
+ - Regular users: can see only their own organizations (as owner or member) without pagination
+
+ **Two response formats based on user role**:
+
+ 1. **ADMIN Users** (paginated response):
+ - Returns all organizations in the system with pagination
+ - Supports search filtering by organization name
+ - Use `offset` and `limit` to paginate through results
+ - Returns object with `data` array and `pagination` metadata
+
+ 2. **Regular Users** (non-paginated response):
+ - Returns only organizations where user is owner or member
+ - No pagination applied
+ - Returns object with `data` array only (no pagination metadata)
+ - Query parameters for pagination are ignored
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Retrieves all services operated by Pricing4SaaS
- description: Retrieves all services operated by Pricing4SaaS
- operationId: getServices
parameters:
- - name: name
+ - name: q
in: query
- description: Name to be considered for filter
required: false
schema:
type: string
- example: Zoom
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/Offset'
- - $ref: '#/components/parameters/Limit'
- - $ref: '#/components/parameters/Order'
+ description: |
+ Search query to match against organization names (case-insensitive regex).
+ Only applies to ADMIN users.
+ - If provided: filters organizations by name
+ - If not provided or empty: returns all organizations
+ example: ACME
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 50
+ default: 10
+ description: |
+ Maximum number of results to return per page.
+ Only applies to ADMIN users. Regular users receive all their organizations.
+ example: 20
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ description: |
+ Number of records to skip (for pagination).
+ Only applies to ADMIN users.
+ example: 0
responses:
'200':
- description: Successful operation
+ description: Organizations list (format varies by user role)
content:
application/json:
schema:
- $ref: '#/components/schemas/Service'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Service not found
- default:
- description: Unexpected error
+ oneOf:
+ - type: object
+ description: Paginated response for ADMIN users
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/Organization'
+ description: Array of organization objects
+ pagination:
+ type: object
+ properties:
+ offset:
+ type: integer
+ description: Current offset value
+ example: 0
+ limit:
+ type: integer
+ description: Current limit value
+ example: 10
+ total:
+ type: integer
+ description: Total number of organizations (filtered by search query if provided)
+ example: 42
+ page:
+ type: integer
+ description: Current page number (1-indexed)
+ example: 1
+ pages:
+ type: integer
+ description: Total number of pages
+ example: 5
+ - type: object
+ description: Non-paginated response for regular users
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/Organization'
+ description: Array of organizations where user is owner or member
+ examples:
+ adminSearchResults:
+ summary: ADMIN - Search results for organizations matching "ACME"
+ value:
+ data:
+ - id: 507f1f77bcf86cd799439011
+ name: ACME Corporation
+ owner: john_doe
+ members: []
+ apiKeys:
+ - key: org_abc123
+ scope: ALL
+ default: true
+ - id: 507f1f77bcf86cd799439012
+ name: ACME Industries
+ owner: jane_smith
+ members:
+ - username: john_doe
+ role: MANAGER
+ apiKeys:
+ - key: org_def456
+ scope: ALL
+ default: false
+ pagination:
+ offset: 0
+ limit: 10
+ total: 2
+ page: 1
+ pages: 1
+ adminListAll:
+ summary: ADMIN - List of all organizations (page 1)
+ value:
+ data:
+ - id: 507f1f77bcf86cd799439011
+ name: ACME Corporation
+ owner: john_doe
+ members: []
+ apiKeys:
+ - key: org_abc123
+ scope: ALL
+ default: true
+ - id: 507f1f77bcf86cd799439012
+ name: Tech Startup
+ owner: jane_smith
+ members: []
+ apiKeys:
+ - key: org_def456
+ scope: ALL
+ default: false
+ pagination:
+ offset: 0
+ limit: 10
+ total: 42
+ page: 1
+ pages: 5
+ regularUserOrganizations:
+ summary: Regular User - Organizations where user is owner or member
+ value:
+ data:
+ - id: 507f1f77bcf86cd799439011
+ name: My Organization
+ owner: john_doe
+ members: []
+ apiKeys:
+ - key: org_abc123
+ scope: ALL
+ default: true
+ - id: 507f1f77bcf86cd799439013
+ name: Shared Project
+ owner: jane_smith
+ members:
+ - username: john_doe
+ role: EVALUATOR
+ apiKeys:
+ - key: org_ghi789
+ scope: ALL
+ default: false
+ '400':
+ description: Invalid query parameters
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ invalidLimit:
+ summary: Limit out of range
+ value:
+ error: "INVALID DATA: Limit must be between 1 and 50"
+ invalidOffset:
+ summary: Negative offset
+ value:
+ error: "INVALID DATA: Offset must be a non-negative number"
+ '401':
+ description: Unauthorized - missing or invalid API key
+
post:
+ summary: Create organization
+ description: |
+ Creates a new organization with the specified owner and details.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - ADMIN users: can create for any user
+ - Regular users: can only create for themselves
+
+ **Automatic Setup**:
+ - Creator becomes organization owner
+ - Organization receives initial API Key with ALL scope
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Adds a new service to the configuration
- description: >-
- Adds a new service to the configuration and stablishes the uploaded
- pricing as the latest version
- operationId: addService
requestBody:
- description: Create a service to be managed by Pricing4SaaS
+ required: true
content:
- multipart/form-data:
+ application/json:
schema:
type: object
+ required:
+ - name
+ - owner
properties:
- pricing:
+ name:
type: string
- format: binary
- description: >
- Either a pricing configuration file in YAML format (.yaml or .yml), or a URL pointing to the pricing in a repository such as [SPHERE](https://sphere.score.us.es)
- required: true
+ example: ACME Corporation
+ owner:
+ type: string
+ example: john_doe
responses:
- '200':
- description: Service created
+ '201':
+ description: Organization created
content:
application/json:
schema:
- $ref: '#/components/schemas/Service'
+ $ref: '#/components/schemas/Organization'
'400':
- description: There is already a service created with this name
- '401':
- description: Authentication required
+ description: Invalid organization data
'403':
- description: Forbidden
- '415':
- description: >-
- File format not allowed. Please provide the pricing in .yaml or .yml
- formats
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
- delete:
- tags:
- - services
- security:
- - ApiKeyAuth: []
- summary: Deletes all services from the configuration
- description: |-
- Deletes all services from the configuration.
+ description: Forbidden - cannot create for other users (USER role)
+ '404':
+ description: Owner user does not exist
+ '409':
+ description: Owner already has a default organization
- **WARNING:** This operation is extremelly destructive.
- operationId: deleteServices
- responses:
- '204':
- description: Services deleted
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
- /services/{serviceName}:
- parameters:
- - $ref: '#/components/parameters/ServiceName'
+ /organizations/{organizationId}:
get:
+ summary: Get organization details
+ description: |
+ Retrieves complete information about a specific organization including
+ members, API keys, and metadata.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user can only view organizations they own or are a member of
+ - ADMIN users can view any organization
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Retrieves a service from the configuration
- description: Retrieves a service's information from the configuration by name
- operationId: getServiceByName
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: MongoDB ObjectId
+ example: 507f1f77bcf86cd799439011
responses:
'200':
- description: Successful operation
+ description: Organization details
content:
application/json:
schema:
- $ref: '#/components/schemas/Service'
+ $ref: '#/components/schemas/Organization'
'401':
- description: Authentication required
+ description: Unauthorized
'403':
- description: Forbidden
+ description: Forbidden - not a member of this organization
'404':
- description: Service not found
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ description: Organization not found
+
put:
+ summary: Update organization
+ description: |
+ Updates organization properties including name and ownership.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Organization OWNER: can update name and transfer ownership
+ - Organization ADMIN: can update name only
+ - Organization MANAGER: can update name only
+ - ADMIN user: can update name and transfer ownership
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Updates a service from the configuration
- description: >-
- Updates a service information from the configuration.
-
-
- **DISCLAIMER**: this endpoint cannot be used to change the pricing of a
- service.
- operationId: updateServiceByName
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
requestBody:
- description: Update a service managed by Pricing4SaaS
+ required: true
content:
application/json:
schema:
@@ -476,524 +925,2067 @@ paths:
properties:
name:
type: string
- description: The new name of the service
- required: true
+ example: Updated Organization Name
+ owner:
+ type: string
+ example: new_owner_username
+ description: Must be an existing username (owner permission required)
responses:
'200':
- description: Service updated
+ description: Organization updated
content:
application/json:
schema:
- $ref: '#/components/schemas/Service'
- '401':
- description: Authentication required
+ $ref: '#/components/schemas/Organization'
+ '400':
+ description: Invalid update data
'403':
- description: Forbidden
+ description: Forbidden - insufficient permissions
'404':
- description: Service not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ description: Organization or specified owner not found
+ '409':
+ description: New owner already has a default organization
+
delete:
+ summary: Delete organization
+ description: |
+ Deletes an organization and all its resources (services, contracts, API keys, members).
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Organization OWNER: can delete the organization they own
+ - ADMIN user: can delete any organization
+
+ **Constarints**:
+ - Cannot delete default organizations
+
+ **Cascading Deletions**:
+ - All services and pricing versions are deleted
+ - All contracts are deleted
+ - All members are removed
+ - All API keys are invalidated
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Disables a service from the configuration
- description: >-
- Disables a service in the configuration, novating all affected contract subscriptions to remove the service.
-
-
- All contracts whose only service was the one disabled will also be deactivated.
-
-
- **WARNING:** This operation disables the service, but do not remove it from the database, so that pricing information can be accessed with support purposes.
- operationId: deleteServiceByName
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
'204':
- description: Service deleted
+ description: Organization deleted successfully
'401':
- description: Authentication required
+ description: Unauthorized
'403':
- description: Forbidden
+ description: Forbidden - cannot delete default organization or insufficient permissions
'404':
- description: Service not found
- default:
- description: Unexpected error
+ description: Organization not found
+
+ /organizations/{organizationId}/members:
+ post:
+ summary: Add member to organization
+ description: |
+ Adds a user to the organization with a specified role.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Organization OWNER: can add any member with any role
+ - Organization ADMIN: can add any member with any role
+ - Organization MANAGER: can add any member with MANAGER/EVALUATOR roles (not OWNER/ADMIN)
+ - ADMIN user: can add with any role
+
+ tags:
+ - Organizations
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - username
+ - role
+ properties:
+ username:
+ type: string
+ example: user_to_add
+ role:
+ type: string
+ enum: [ADMIN, MANAGER, EVALUATOR]
+ example: MANAGER
+ responses:
+ '200':
+ description: Member added successfully
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- /services/{serviceName}/pricings:
- parameters:
- - $ref: '#/components/parameters/ServiceName'
- get:
+ $ref: '#/components/schemas/Organization'
+ '400':
+ description: Invalid member data
+ '403':
+ description: Forbidden - cannot grant this role (insufficient permissions)
+ '404':
+ description: Organization or user not found
+
+ /organizations/{organizationId}/members/{username}:
+ put:
+ summary: Update member role in organization
+ description: |
+ Updates the role of a member within the organization. Only users with appropriate permissions can change member roles.
+
+ **Authentication**: User API Key
+
+ **Permission Model**:
+ - **SPACE Admin**: Can promote/demote any member to any role (except OWNER)
+ - **Organization OWNER**: Can promote/demote any member (except themselves) to any role (except OWNER)
+ - **Organization ADMIN**: Can promote/demote any member (except OWNER and other ADMINs) to any role (except OWNER and ADMIN)
+ - **Organization MANAGER**: Can promote/demote MANAGER and EVALUATOR members to lower roles (MANAGER/EVALUATOR only)
+ - **Organization EVALUATOR and below**: No permission to change roles
+
+ **Available Roles**: ADMIN, MANAGER, EVALUATOR
+
+ **Constraints**:
+ - Cannot change OWNER's role (organization owner cannot be downgraded)
+ - Cannot assign OWNER role (OWNER role is special, only changed via organization transfer)
+ - Cannot update a member to their current role (must be a different role)
+ - Member must initially exist in the organization
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Retrieves pricings of a service from the configuration
- description: >-
- Retrieves either active or archived pricings of a service from the
- configuration
- operationId: getServicePricingsByName
parameters:
- - name: pricingStatus
- in: query
- description: Pricing status to be considered for filter
- required: false
+ - name: organizationId
+ in: path
+ required: true
+ description: The organization ID (must be valid MongoDB ObjectId)
+ schema:
+ type: string
+ format: uuid
+ - name: username
+ in: path
+ required: true
+ description: The username of the member whose role is being updated
schema:
type: string
- enum:
- - active
- - archived
- default: active
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - role
+ properties:
+ role:
+ type: string
+ enum: [ADMIN, MANAGER, EVALUATOR]
+ description: The new role to assign to the member
+ examples:
+ promoteToAdmin:
+ summary: Promote member to ADMIN
+ value:
+ role: ADMIN
+ demoteToEvaluator:
+ summary: Demote member to EVALUATOR
+ value:
+ role: EVALUATOR
responses:
'200':
- description: Successful operation
+ description: Member role updated successfully
content:
application/json:
schema:
- type: array
- items:
- $ref: '#/components/schemas/Pricing'
- '401':
- description: Authentication required
+ $ref: '#/components/schemas/Organization'
+ '400':
+ description: Invalid data or request
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ nonExistentMember:
+ summary: Member does not exist
+ value:
+ error: "INVALID DATA: User with username nonexistent_user is not a member of the organization."
+ ownerRole:
+ summary: Cannot change owner's role
+ value:
+ error: "INVALID DATA: Cannot change the role of the organization owner."
+ invalidOrganization:
+ summary: Invalid organization ID format
+ value:
+ error: "INVALID DATA: Invalid organization ID"
'403':
- description: Forbidden
+ description: Forbidden - User lacks required permissions
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ noPermission:
+ summary: User cannot update member roles
+ value:
+ error: "PERMISSION ERROR: Only SPACE admins or organization-level OWNER, ADMIN and MANAGER can update member roles."
+ escalatedPermission:
+ summary: Manager trying to promote to ADMIN
+ value:
+ error: "PERMISSION ERROR: Only SPACE admins or organization-level OWNER/ADMIN can add high-level members."
'404':
- description: Service not found
- default:
- description: Unexpected error
+ description: Organization not found
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- post:
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ notFound:
+ summary: Organization does not exist
+ value:
+ error: "Organization with ID 000000000000000000000000 not found"
+ '409':
+ description: Conflict - Member already has the target role
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ sameRole:
+ summary: Member already has this role
+ value:
+ error: "CONFLICT: User with username john_doe already has the role EVALUATOR."
+ '422':
+ description: Validation error
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ missingRole:
+ summary: Missing role field
+ value:
+ error: "Validation error: role field is required"
+ invalidRole:
+ summary: Invalid role value
+ value:
+ error: "Validation error: role must be one of ADMIN, MANAGER, EVALUATOR"
+ invalidRoleType:
+ summary: Role is not a string
+ value:
+ error: "Validation error: role must be a string"
+ ownerRoleAttempt:
+ summary: Attempting to assign OWNER role
+ value:
+ error: "Validation error: OWNER role cannot be assigned via this endpoint"
+
+ delete:
+ summary: Remove specific member from organization
+ description: |
+ Removes a user from the organization.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Organization OWNER: can remove any member
+ - Organization ADMIN: can remove members except other ADMINs or OWNER
+ - Organization MANAGER: can remove MANAGER/EVALUATOR only
+ - Organization EVALUATOR: can only remove themselves
+ - ADMIN user: can remove any member
+
tags:
- - services
+ - Organizations
security:
- ApiKeyAuth: []
- summary: Adds pricing to service
- description: >-
- Adds a new **active** pricing to the service.
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: username
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Member removed successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Organization'
+ '403':
+ description: Forbidden
+ '404':
+ description: Organization or member not found
+
+ /organizations/{organizationId}/api-keys:
+ post:
+ summary: Create organization API Key
+ description: |
+ Creates a new API Key for the organization with the specified scope.
+
+ **Authentication**: User API Key
+ **Permission**:
+ - Organization OWNER: can create any scope
+ - Organization ADMIN: can create any scope
+ - Organization MANAGER: can create MANAGEMENT/EVALUATION scopes only
+ - ADMIN user: can create any scope
- **IMPORTANT:** both the service's name and the pricing's must be the
- same.
- operationId: addPricingToServiceByName
+ **Scopes**:
+ - `ALL`: Unrestricted access to all organization operations
+ - `MANAGEMENT`: Create, update, delete operations (read access included)
+ - `EVALUATION`: Read-only access to services/pricings + feature evaluation
+
+ tags:
+ - Organizations
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
requestBody:
- description: Adds a pricing to an existent service
+ required: true
content:
- multipart/form-data:
+ application/json:
schema:
type: object
+ required:
+ - scope
properties:
- pricing:
+ scope:
type: string
- format: binary
- description: >-
- Either a pricing configuration file in YAML format (.yaml or .yml), or a URL pointing to the pricing in a repository such as [SPHERE](https://sphere.score.us.es)
- required: true
+ enum: [ALL, MANAGEMENT, EVALUATION]
+ example: MANAGEMENT
responses:
'200':
- description: Pricing added
+ description: API Key created successfully
content:
application/json:
schema:
- $ref: '#/components/schemas/Service'
+ $ref: '#/components/schemas/Organization'
'400':
- description: The service already have a pricing with this version
- '401':
- description: Authentication required
+ description: Invalid scope value
'403':
- description: Forbidden
+ description: Forbidden - cannot grant this scope (MANAGER cannot create ALL scope)
'404':
- description: Service not found
- '415':
- description: >-
- File format not allowed. Please provide the pricing in .yaml or .yml
- formats
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
+ description: Organization not found
+
+ /organizations/{organizationId}/api-keys/{apiKeyId}:
+ delete:
+ summary: Remove organization API Key
+ description: |
+ Removes an API Key from the organization, immediately invalidating it.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - Organization OWNER: can remove any key
+ - Organization ADMIN: can remove any key
+ - Organization MANAGER: can remove non-ALL scope keys only
+ - ADMIN user: can remove any key
+
+ tags:
+ - Organizations
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: apiKey
+ in: path
+ required: true
+ schema:
+ type: string
+ description: The API Key value to remove
+ responses:
+ '200':
+ description: API Key removed successfully
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- /services/{serviceName}/pricings/{pricingVersion}:
- parameters:
- - $ref: '#/components/parameters/ServiceName'
- - $ref: '#/components/parameters/PricingVersion'
+ $ref: '#/components/schemas/Organization'
+ '403':
+ description: Forbidden - cannot remove ALL-scope key (MANAGER)
+ '404':
+ description: API Key not found
+
+ /organizations/{organizationId}/services:
get:
+ summary: List services in organization
+ description: |
+ Lists all services configured in the organization.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must be an organization member with any role (OWNER/ADMIN/MANAGER/EVALUATOR)
+ - ADMIN user: can view services in any organization
+
tags:
- - services
+ - Services
security:
- ApiKeyAuth: []
- summary: Retrieves a pricing from the configuration
- description: Retrieves a pricing configuration
- operationId: getServicePricingByVersion
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
'200':
- description: Successful operation
+ description: List of services
content:
application/json:
schema:
- $ref: '#/components/schemas/Pricing'
+ type: array
+ items:
+ $ref: '#/components/schemas/Service'
'401':
- description: Authentication required
+ description: Unauthorized
'403':
- description: Forbidden
- '404':
- description: Service or pricing not found
- default:
- description: Unexpected error
+ description: Forbidden - not a member of this organization or insufficient scope
+
+ post:
+ summary: Create service in organization
+ description: |
+ Creates a new service in the organization.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN/MANAGER role required
+ - ADMIN user: can create services in any organization
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required:
+ - name
+ - description
+ properties:
+ name:
+ type: string
+ example: Zoom
+ description:
+ type: string
+ example: Video conferencing and web meeting platform
+ pricing:
+ type: string
+ format: binary
+ description: Optional pricing YAML/JSON file
+ responses:
+ '201':
+ description: Service created successfully
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- put:
+ $ref: '#/components/schemas/Service'
+ '400':
+ description: Invalid service data
+ '403':
+ description: Forbidden - insufficient permissions
+ '404':
+ description: Organization not found
+
+ delete:
+ summary: Delete all services in organization
+ description: |
+ Deletes all services and associated pricings in the organization (irreversible operation).
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN role required
+ - ADMIN user: can delete services in any organization
+
tags:
- - services
+ - Services
security:
- ApiKeyAuth: []
- summary: Changes a pricing's availavility for a service
- description: >-
- Changes a pricing's availavility for a service.
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: All services deleted successfully
+ '403':
+ description: Forbidden - insufficient permissions
+ '404':
+ description: Organization not found
+
+ /organizations/{organizationId}/services/{serviceName}:
+ get:
+ summary: Get service details
+ description: |
+ Retrieves detailed information about a specific service in a organization.
+
+ **Authentication**: User API Key
+ **Permission**:
+ - USER user: must be an organization member with any role (OWNER/ADMIN/MANAGER/EVALUATOR)
+ - ADMIN user: can view service details in any organization
- **WARNING:** This is a potentially destructive action. All users
- subscribed to a pricing that is going to be archived will suffer
- novations to the most recent version of the pricing.
- operationId: updatePricingAvailabilityByVersion
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ example: Zoom
+ responses:
+ '200':
+ description: Service details including pricings
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Service'
+ '403':
+ description: Forbidden
+ '404':
+ description: Service not found
+
+ put:
+ summary: Update service
+ description: |
+ Updates service metadata (name, organization).
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN/MANAGER role required
+ - ADMIN user: can update services in any organization
+
+ **Cascading Operations**:
+ - Renaming a service propagates the change by updating the corresponding `contractedServices` references in all associated contracts.
+ - Updating `organizationId` triggers one of the following behaviors:
+ - If contracts include **only the updated service**, each contract’s `organizationId`` is updated to reflect the new organization.
+ - If contracts include **multiple contracted services**, the updated service is removed from them.
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ organizationId:
+ type: string
+ responses:
+ '200':
+ description: Service updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Service'
+ '400':
+ description: Invalid update data (e.g., organization not found)
+ '403':
+ description: Forbidden
+ '404':
+ description: Service not found
+ '409':
+ description: Service name already exists
+
+ delete:
+ summary: Disable service
+ description: |
+ Disables a service. Cannot be permanently deleted; use disable to mark unavailable.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN role required
+ - ADMIN user: can disable services in any organization
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Service disabled successfully
+ '403':
+ description: Forbidden
+ '404':
+ description: Service not found
+
+ /organizations/{organizationId}/services/{serviceName}/pricings:
+ get:
+ summary: List service pricings
+ description: |
+ Lists all pricing versions for a specific service in a organization.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: organization member with any role
+ - ADMIN user: can view pricings of any service in any organization
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: List of pricing versions
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Pricing'
+ '403':
+ description: Forbidden
+ '404':
+ description: Service not found
+
+ post:
+ summary: Add pricing version to service
+ description: |
+ Uploads and adds a new pricing version to the service.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN/MANAGER role required
+ - ADMIN user: can add pricings to any service in any organization
+
+ **Constraints**:
+ - Pricing `saasName` and `serviceName` must be the same
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required:
+ - pricing
+ properties:
+ pricing:
+ type: string
+ format: binary
+ description: Pricing file in YAML or JSON format
+ responses:
+ '201':
+ description: Pricing version added
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pricing'
+ '400':
+ description: Invalid pricing data
+ '403':
+ description: Forbidden
+ '404':
+ description: Service not found
+
+ /organizations/{organizationId}/services/{serviceName}/pricings/{pricingVersion}:
+ get:
+ summary: Get pricing version details
+ description: |
+ Retrieves complete details about a specific pricing version including
+ plans, add-ons, and availability.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: organization member with any role
+ - ADMIN user: can view any pricing version from any service in any organization
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: pricingVersion
+ in: path
+ required: true
+ schema:
+ type: string
+ example: "1.0.0"
+ responses:
+ '200':
+ description: Pricing version details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pricing'
+ '404':
+ description: Pricing version not found
+
+ put:
+ summary: Update pricing availability
+ description: |
+ Updates the availability status of a pricing version.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN/MANAGER role required
+ - ADMIN user: can update availability of any pricing version from any service in any organization
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: pricingVersion
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ available:
+ type: boolean
+ example: true
+ responses:
+ '200':
+ description: Pricing availability updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pricing'
+ '403':
+ description: Forbidden
+ '404':
+ description: Pricing version not found
+
+ delete:
+ summary: Delete pricing version
+ description: |
+ Removes a pricing version from the given service in the given organization (irreversible operation).
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: OWNER/ADMIN role required
+ - ADMIN user: can delete any pricing version from any service in any organization
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: pricingVersion
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Pricing version deleted successfully
+ '403':
+ description: Forbidden
+ '404':
+ description: Pricing version not found
+
+ /services:
+ get:
+ summary: List all services
+ description: |
+ Lists all services configured in the organization.
+
+ **Authentication**: Organization API Key | User API Key
+
+ **Organization API Key Permission**:
+ - ALL scope: can view and filter all services from the organization
+ - MANAGEMENT scope: can view and filter all services from the organization
+ - EVALUATION scope: can view and filter all services from the organization
+
+ **User API Key Permission**:
+ - ADMIN user: can view all services from any organization
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: name
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Case-insensitive partial match on service name (regex "contains").
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ default: 1
+ description: Page number for pagination. Ignored when `offset` is provided and greater than 0.
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ default: 0
+ description: Number of items to skip. When `offset` is greater than 0, it overrides `page`.
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ default: 20
+ description: Max number of items to return per request.
+ - name: order
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [asc, desc]
+ default: asc
+ description: Sort order by service name.
+ responses:
+ '200':
+ description: List of all services
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Service'
+ '401':
+ description: Unauthorized or invalid API key
+ '403':
+ description: Forbidden - User API Keys cannot access this endpoint
+
+ post:
+ summary: Create service
+ description: |
+ Creates a new service in the organization.
+
+ **Authentication**: Organization API Key
+
+ **Permission**:
+ - Org Key with ALL or MANAGEMENT scope
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required:
+ - name
+ - description
+ properties:
+ name:
+ type: string
+ description:
+ type: string
+ pricing:
+ type: string
+ format: binary
+ responses:
+ '201':
+ description: Service created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Service'
+ '403':
+ description: Forbidden
+
+ delete:
+ summary: Delete all services
+ description: |
+ Deletes all services and associated pricings in the organization (irreversible operation).
+
+ **Authentication**: Organization API Key | User API Key
+
+ **Organization Permission**:
+ - ALL scope: delete all services from the organization.
+
+ **User Permission**:
+ - ADMIN user: can delete all services allocated in a SPACE instance.
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ responses:
+ '204':
+ description: All services deleted
+ '403':
+ description: Forbidden
+
+ /services/{serviceName}:
+ get:
+ summary: Get service
+ description: |
+ Retrieves detailed information about a specific service in a organization.
+
+ **Authentication**: Organization API Key
+
+ **Permission**: Org Key with any scope
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Service details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Service'
+ '404':
+ description: Service not found
+
+ put:
+ summary: Update service
+ description: |
+ Updates service metadata (name, organization).
+
+ **Authentication**: Organization API Key
+
+ **Permission**:
+ - ALL scope: can update service name and organization.
+ - MANAGEMENT scope: can update service name and organization
+
+ **Cascading Operations**:
+ - Renaming a service propagates the change by updating the corresponding `contractedServices` references in all associated contracts.
+ - Updating `organizationId` triggers one of the following behaviors:
+ - If contracts include **only the updated service**, each contract’s `organizationId` is updated to reflect the new organization.
+ - If contracts include **multiple contracted services**, the updated service is removed from them.
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ organizationId:
+ type: string
+ responses:
+ '200':
+ description: Service updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Service'
+ '403':
+ description: Forbidden
+
+ delete:
+ summary: Disable service
+ description: |
+ Disables a service. Cannot be permanently deleted; use disable to mark unavailable.
+
+ **Authentication**: Organization API Key
+
+ **Permission**:
+ - ALL scope: can disable any service from the organization.
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Service disabled
+ '403':
+ description: Forbidden
+
+ /services/{serviceName}/pricings:
+ get:
+ summary: List pricings
+ description: |
+ Lists all pricing versions for a specific service in a organization.
+
+ **Authentication**: Organization API Key
+
+ **Permission**: Org Key with any scope
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: List of pricings
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Pricing'
+ '404':
+ description: Service not found
+
+ post:
+ summary: Add pricing
+ description: |
+ Uploads and adds a new pricing version to the service.
+
+ **Constraints**:
+ - Pricing `saasName` and `serviceName` must be the same
+
+ **Authentication**: Organization API Key
+
+ **Permission**:
+ - ALL scope: can add pricings to any service in the organization.
+ - MANAGEMENT scope: can add pricings to any service in the organization.
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ pricing:
+ type: string
+ format: binary
+ responses:
+ '201':
+ description: Pricing added
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pricing'
+ '403':
+ description: Forbidden
+
+ /services/{serviceName}/pricings/{pricingVersion}:
+ get:
+ summary: Get pricing
+ description: |
+ Retrieves complete details about a specific pricing version including
+ plans, add-ons, and availability.
+
+ **Authentication**: Organization API Key
+
+ **Permission**: Org Key with any scope
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: pricingVersion
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Pricing details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pricing'
+ '404':
+ description: Pricing not found
+
+ put:
+ summary: Update pricing availability
+ description: |
+ Updates the availability status of a pricing version.
+
+ **Authentication**: Organization API Key only
+
+ **Permission**:
+ - ALL scope: can update availability of any pricing version from any service in the organization.
+ - MANAGEMENT scope: can update availability of any pricing version from any service in the organization.
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: pricingVersion
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ available:
+ type: boolean
+ responses:
+ '200':
+ description: Pricing updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pricing'
+ '403':
+ description: Forbidden
+
+ delete:
+ summary: Delete pricing
+ description: |
+ Removes a pricing version from the given service in the given organization (irreversible operation).
+
+ **Authentication**: Organization API Key
+
+ **Permission**:
+ - ALL scope: can delete any pricing version from any service in the organization.
+
+ tags:
+ - Services
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: serviceName
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: pricingVersion
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Pricing deleted
+ '403':
+ description: Forbidden
+
+ /organizations/{organizationId}/contracts:
+ get:
+ summary: List contracts in organization
+ description: |
+ Lists all contracts in the organization with optional filtering and pagination.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must be an organization member with any role (OWNER/ADMIN/MANAGER/EVALUATOR)
+ - ADMIN user: can view contracts in any organization
+
+ **Request Filters**:
+ - Query parameters (`/organizations/{id}/contracts?username=john&limit=50`): Basic filters with pagination
+ - Request body (with filters object): Complex/advanced filtering via JSON body
+ - Combined: Both query and body filters are merged (body filters override query filters)
+
+ **Response Format**:
+ - Plain array of Contract objects (not paginated wrapper)
+ - Total count provided via `X-Total-Count` response header
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ description: The organization ID (must be valid MongoDB ObjectId)
+ example: 507f1f77bcf86cd799439011
+ - name: username
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Filter contracts by username (case-insensitive regex match)
+ example: john_doe
+ - name: firstName
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Filter contracts by user's first name (case-insensitive regex match)
+ example: John
+ - name: lastName
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Filter contracts by user's last name (case-insensitive regex match)
+ example: Doe
+ - name: email
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Filter contracts by user email (case-insensitive regex match)
+ example: john@example.com
+ - name: groupId
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Filter contracts by group ID
+ example: my-group
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 1
+ minimum: 1
+ description: |
+ Page number for pagination (1-based).
+ **Note**: Ignored when `offset > 0`.
+ example: 1
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 0
+ minimum: 0
+ description: |
+ Number of contracts to skip (0-based offset).
+ **Precedence**: When `offset > 0`, it overrides the `page` parameter.
+ example: 0
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 20
+ minimum: 1
+ description: Maximum number of contracts to return per page
+ example: 20
+ - name: sort
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [firstName, lastName, username, email]
+ description: Field to sort contracts by
+ example: firstName
+ - name: order
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [asc, desc]
+ default: asc
+ description: Sort order (ascending or descending)
+ example: asc
+ responses:
+ '200':
+ description: List of contracts (plain array with total count in header)
+ headers:
+ X-Total-Count:
+ schema:
+ type: integer
+ description: Total number of contracts matching the filters
+ example: 25
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Contract'
+ examples:
+ contractsList:
+ summary: List of contracts for organization
+ value:
+ - id: 507f1f77bcf86cd799439011
+ userId: user123
+ organizationId: org456
+ userContact:
+ username: john_doe
+ firstName: John
+ lastName: Doe
+ email: john@example.com
+ billingPeriod:
+ startDate: "2024-01-01"
+ endDate: "2024-12-31"
+ contractedServices: []
+ subscriptionPlans: []
+ subscriptionAddons: []
+ subscriptionSnapshot: {}
+ usageLevels: []
+ '400':
+ description: Invalid query parameters or request body
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '401':
+ description: Unauthorized - missing or invalid API key
+ '403':
+ description: Forbidden - not a member of this organization
+
+ post:
+ summary: Create contract in organization
+ description: |
+ Creates a new subscription contract in the organization.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must have OWNER/ADMIN/MANAGER role
+ - ADMIN user: can create contracts in any organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ $ref: '#/components/requestBodies/SubscriptionCreation'
+ responses:
+ '201':
+ description: Contract created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Contract'
+ '403':
+ description: Forbidden
+
+ put:
+ summary: Update contracts by group ID
+ description: |
+ Updates the details of all contracts in a group (contract novation - subscription composition change).
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can update contracts in any organization
+
+ **Organization Permission**:
+ - ALL scope: can update contracts in the organization
+ - MANAGEMENT scope: can update contracts in the organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: groupId
+ in: query
+ required: true
+ schema:
+ type: string
+ requestBody:
+ $ref: '#/components/requestBodies/SubscriptionCompositionNovation'
+ responses:
+ '200':
+ description: Contracts updated
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Contract'
+
+ delete:
+ summary: Delete all contracts in organization
+ description: |
+ Deletes all subscription contracts in the organization (irreversible).
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must have OWNER/ADMIN role with the organization
+ - ADMIN user: can delete contracts in any organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: All contracts deleted
+ '403':
+ description: Forbidden
+
+ /organizations/{organizationId}/contracts/{userId}:
+ get:
+ summary: Get contract details
+ description: |
+ Retrieves details of a specific user's contract in the organization.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must be an organization member with any role (OWNER/ADMIN/MANAGER/EVALUATOR)
+ - ADMIN user: can view contracts in any organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Contract details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Contract'
+ '404':
+ description: Contract not found
+
+ put:
+ summary: Update contract (novate)
+ description: |
+ Updates contract details (contract novation - subscription composition change).
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must have OWNER/ADMIN/MANAGER role
+ - ADMIN user: can update contracts in any organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
parameters:
- - name: availability
- in: query
- description: >-
- Use this query param to change wether a pricing is active or
- archived for a service.
-
-
- **IMPORTANT:** If the pricing is the only active pricing of the
- service, it cannot be archived.
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: userId
+ in: path
required: true
schema:
type: string
- enum:
- - active
- - archived
- example: archived
requestBody:
- description: >-
- If `availability = "archived"`, the request body must include a fallback subscription. This subscription will be used to novate all contracts currently subscribed to the pricing version being archived. The fallback subscription must be valid in the latest version of the pricing, as this is the version to which all contracts will be migrated.
-
-
- **IMPORTANT:** If `availability = "archived"`, the request body is **required**
- content:
- application/json:
- schema:
- type: object
- properties:
- subscriptionPlan:
- type: string
- description: >-
- The plan selected fo the new subscription
- subscriptionAddOns:
- type: object
- description: >-
- The set of add-ons to be included in the new subscription
- additionalProperties:
- type: number
- description: Indicates how many times the add-on is contracted
- example:
- subscriptionPlan: "PRO"
- additionalAddOns:
- largeMeetings: 1
- zoomWhiteboard: 1
+ $ref: '#/components/requestBodies/SubscriptionCompositionNovation'
responses:
'200':
- description: Service updated
+ description: Contract updated
content:
application/json:
schema:
- $ref: '#/components/schemas/Service'
- '400':
- description: >-
- Pricing cannot be archived because is the last active one of the
- service
- '401':
- description: Authentication required
+ $ref: '#/components/schemas/Contract'
'403':
description: Forbidden
- '404':
- description: Service or pricing not found
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+
delete:
+ summary: Delete contract
+ description: |
+ Deletes a specific subscription contract.
+
+ **Authentication**: User API Key
+
+ **Permission**:
+ - USER user: must have OWNER/ADMIN role in the organization
+ - ADMIN user: can delete contracts in any organization
+
tags:
- - services
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Deletes a pricing version from a service
- description: >-
- Deletes a pricing version from a service.
-
-
- **WARNING:** This is a potentially destructive action. All users
- subscribed to a pricing that is going to be deleted will suffer
- novations in their contracts towards the latests pricing version of the
- service. If the removed pricing is the **last active pricing of the
- service, the service will be deleted**.
- operationId: deletePricingByVersionAndService
+ parameters:
+ - name: organizationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
'204':
- description: Pricing deleted
- '401':
- description: Authentication required
+ description: Contract deleted
'403':
description: Forbidden
- '404':
- description: Service or pricing not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+
/contracts:
get:
+ summary: List all contracts
+ description: |
+ Lists all contracts with optional filtering and pagination.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can view all contracts from any organization (without organizationId filter)
+
+ **Organization Permission**:
+ - ALL scope: can view all contracts from the organization
+ - MANAGEMENT scope: can view all contracts from the organization
+
+ **Request Filters**:
+ - Query parameters (`/contracts?username=john&limit=50`): Basic filters with pagination
+ - Request body (`POST` with filters object): Complex/advanced filtering via JSON body
+ - Combined: Both query and body filters are merged (body filters override query filters)
+
+ **Response Format**:
+ - Plain array of Contract objects (not paginated wrapper)
+ - Total count provided via `X-Total-Count` response header
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Retrieves all the contracts of the SaaS
- description: >-
- Retrieves all SaaS contracts, with pagination set to 20 per page by
- default.
- operationId: getContracts
parameters:
- name: username
in: query
- description: The username of the user for filter
required: false
schema:
- $ref: '#/components/schemas/Username'
+ type: string
+ description: Filter contracts by username (case-insensitive regex match)
+ example: john_doe
- name: firstName
in: query
- description: The first name of the user for filter
required: false
schema:
type: string
- example: John
+ description: Filter contracts by user's first name (case-insensitive regex match)
+ example: John
- name: lastName
in: query
- description: The last name of the user for filter
required: false
schema:
type: string
- example: Doe
+ description: Filter contracts by user's last name (case-insensitive regex match)
+ example: Doe
- name: email
in: query
- description: The email of the user for filter
required: false
schema:
type: string
- example: test@user.com
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/Offset'
- - $ref: '#/components/parameters/Limit'
- - $ref: '#/components/parameters/Order'
+ description: Filter contracts by user email (case-insensitive regex match)
+ example: john@example.com
+ - name: groupId
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Filter contracts by group ID
+ example: my-group
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 1
+ minimum: 1
+ description: |
+ Page number for pagination (1-based).
+ **Note**: Ignored when `offset > 0`.
+ example: 1
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 0
+ minimum: 0
+ description: |
+ Number of contracts to skip (0-based offset).
+ **Precedence**: When `offset > 0`, it overrides the `page` parameter.
+ example: 0
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 20
+ minimum: 1
+ description: Maximum number of contracts to return per page
+ example: 20
- name: sort
in: query
- description: Field name to sort the results by.
required: false
schema:
type: string
- enum:
- - firstName
- - lastName
- - username
- - email
- example: lastName
- default: username
- requestBody:
- description: >-
- Allow to define additional, more-complex filters on the requests regarding subscriptions composition.
- content:
- application/json:
- schema:
- type: object
- properties:
- services:
- oneOf:
- - type: array
- description: >-
- List of services that the subscription must include
- items:
- type: string
- description: Name of the service
- - type: object
- description: >-
- Map containing service names as keys and plans/add-ons
- array that the subscription must include for such
- service as values.
- additionalProperties:
- type: array
- items:
- type: string
- description: Versions of the service
- subscriptionPlans:
- type: object
- description: >-
- Map containing service names as keys and plans array that the
- subscription must include for such service as values.
- additionalProperties:
- type: array
- items:
- type: string
- description: Name of the plan
- subscriptionAddOns:
- type: object
- description: >-
- Map containing service names as keys and add-ons array that
- the subscription must include for such service as values.
- additionalProperties:
- type: array
- items:
- type: string
- description: Name of the add-on
- example:
- subscriptionPlan: "PRO"
- additionalAddOns:
- largeMeetings: 1
- zoomWhiteboard: 1
+ enum: [firstName, lastName, username, email]
+ description: Field to sort contracts by
+ example: firstName
+ - name: order
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [asc, desc]
+ default: asc
+ description: Sort order (ascending or descending)
+ example: asc
responses:
'200':
- description: Successful operation
+ description: List of contracts (plain array with total count in header)
+ headers:
+ X-Total-Count:
+ schema:
+ type: integer
+ description: Total number of contracts matching the filters
+ example: 42
content:
application/json:
schema:
type: array
items:
- $ref: '#/components/schemas/Subscription'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- default:
- description: Unexpected error
+ $ref: '#/components/schemas/Contract'
+ examples:
+ withFilters:
+ summary: Contracts filtered by username and sorted by firstName
+ value:
+ - id: 507f1f77bcf86cd799439011
+ userId: user123
+ organizationId: org456
+ userContact:
+ username: john_doe
+ firstName: John
+ lastName: Doe
+ email: john@example.com
+ billingPeriod:
+ startDate: "2024-01-01"
+ endDate: "2024-12-31"
+ contractedServices: []
+ subscriptionPlans: []
+ subscriptionAddons: []
+ subscriptionSnapshot: {}
+ usageLevels: []
+ - id: 507f1f77bcf86cd799439012
+ userId: user124
+ organizationId: org456
+ userContact:
+ username: johnny_test
+ firstName: Johnny
+ lastName: Test
+ email: johnny@example.com
+ billingPeriod:
+ startDate: "2024-01-01"
+ endDate: "2024-12-31"
+ contractedServices: []
+ subscriptionPlans: []
+ subscriptionAddons: []
+ subscriptionSnapshot: {}
+ usageLevels: []
+ '400':
+ description: Invalid query parameters or request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
+ '401':
+ description: Unauthorized - missing or invalid API key
+ '403':
+ description: Forbidden - insufficient permissions
+
post:
+ summary: Create contract
+ description: |
+ Creates a new subscription contract in the organization.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can create contracts in any organization
+
+ **Organization Permission**:
+ - ALL scope: can create contracts in the organization
+ - MANAGEMENT scope: can create contracts in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Stores a new contract within the system
- description: >-
- Stores a new contract within the system in order to use it in
- evaluations..
- operationId: addContracts
requestBody:
$ref: '#/components/requestBodies/SubscriptionCreation'
responses:
- '200':
- description: Successful operation
+ '201':
+ description: Contract created
content:
application/json:
schema:
- $ref: '#/components/schemas/Subscription'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
+ $ref: '#/components/schemas/Contract'
+
+ put:
+ summary: Update contracts by group ID
+ description: |
+ Updates the details of all contracts in a group (contract novation - subscription composition change).
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can update contracts in any organization
+
+ **Organization Permission**:
+ - ALL scope: can update contracts in the organization
+ - MANAGEMENT scope: can update contracts in the organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: groupId
+ in: query
+ required: true
+ schema:
+ type: string
+ requestBody:
+ $ref: '#/components/requestBodies/SubscriptionCompositionNovation'
+ responses:
+ '200':
+ description: Contracts updated
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: array
+ items:
+ $ref: '#/components/schemas/Contract'
+
delete:
+ summary: Delete all contracts
+ description: |
+ Deletes all subscription contracts in the organization (irreversible).
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can delete all contracts from any organization
+
+ **Organization Permission**:
+ - ALL scope: can delete all contracts from the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Deletes all contracts from the configuration
- description: |-
- Deletes all contracts from the configuration.
-
- **WARNING:** This operation is extremelly destructive.
- operationId: deleteContracts
responses:
'204':
- description: Contracts deleted
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- default:
- description: Unexpected error
+ description: All contracts deleted
+
+ /contracts/billingPeriod:
+ put:
+ summary: Bulk update billing period
+ description: |
+ Updates the billing period configuration for all contracts in a group.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can update billing period in any organization
+
+ **Organization Permission**:
+ - ALL scope: can update billing period in the organization
+ - MANAGEMENT scope: can update billing period in the organization
+
+ tags:
+ - Contracts
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: groupId
+ in: query
+ required: true
+ schema:
+ type: string
+ requestBody:
+ $ref: '#/components/requestBodies/SubscriptionBillingNovation'
+ responses:
+ '200':
+ description: Billing period updated for all contracts in the group
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: array
+ items:
+ $ref: '#/components/schemas/Contract'
+
/contracts/{userId}:
- parameters:
- - $ref: '#/components/parameters/UserId'
get:
+ summary: Get contract
+ description: |
+ Retrieves details of a specific user's contract in the organization.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can view contracts in any organization
+
+ **Organization Permission**:
+ - ALL scope: can view contracts in the organization
+ - MANAGEMENT scope: can view contracts in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Retrieves a contract from the configuration
- description: Retrieves the contract of the given userId
- operationId: getContractByUserId
+ parameters:
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
'200':
- description: Successful operation
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Subscription'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- default:
- description: Unexpected error
+ description: Contract details
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ $ref: '#/components/schemas/Contract'
+
put:
+ summary: Update contract
+ description: |
+ Updates contract details (contract novation - subscription composition change).
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can update contracts in any organization
+
+ **Organization Permission**:
+ - ALL scope: can update contracts in the organization
+ - MANAGEMENT scope: can update contracts in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Updates a contract from the configuration
- description: >-
- Performs a novation over the composition of a user's contract, i.e.
- allows you to change the active plan/add-ons within the contract,
- storing the actual values in the `history`.
- operationId: updateContractByUserId
+ parameters:
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
requestBody:
$ref: '#/components/requestBodies/SubscriptionCompositionNovation'
responses:
@@ -1002,594 +2994,630 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/Subscription'
- '400':
- description: Invalid novation
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ $ref: '#/components/schemas/Contract'
+
delete:
+ summary: Delete contract
+ description: |
+ Deletes a specific subscription contract.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can delete contracts in any organization
+
+ **Organization Permission**:
+ - ALL scope: can delete contracts in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Deletes a contract from the configuration
- description: |-
- Deletes a contract from the configuration.
-
- **WARNING:** This operation also removes all user history.
- operationId: deleteContractByUserId
- responses:
- '204':
- description: Contract deleted
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ parameters:
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Contract deleted
+
/contracts/{userId}/usageLevels:
put:
+ summary: Reset usage levels
+ description: |
+ Resets usage level counters for a contract.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can reset usage levels in any organization
+
+ **Organization Permission**:
+ - ALL scope: can reset usage levels in the organization
+ - MANAGEMENT scope: can reset usage levels in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Updates the usageLevel of a contract
- description: >-
- Performs a novation to either add consumption to some or all usageLevels of a user’s contract, or to reset them.
- operationId: updateContractUsageLevelByUserId
- requestBody:
- description: Updates the value of the the usage levels tracked by a contract
- content:
- application/json:
- schema:
- type: object
- description: >-
- Map containing service names as keys and the increment to be applied to a subset of such service's trackable usage limits as values.
- additionalProperties:
- type: object
- description: >-
- Map containing trackable usage limit names as keys and the increment to be applied to such limits as values.
- additionalProperties:
- type: number
- description: >-
- Increment that is going to be applied to the usage level. **Example:** If the current value of an usage level U of the service S is 1, sending `{S: {U: 5}}` will set the usage level value of U to 6.
- example:
- zoom:
- maxSeats: 10
- petclinic:
- maxPets: 2
- maxVisits: 5
parameters:
- - $ref: '#/components/parameters/UserId'
- - name: reset
- in: query
- description: >-
- Indicates whether to reset all matching quotas to 0. Cannot be used
- with `usageLimit`. Use either `reset` or `usageLimit`, not both
- schema:
- type: boolean
- example: true
- - name: renewableOnly
- in: query
- description: >-
- Indicates whether to reset only **RENEWABLE** matching quotas to 0
- or all of them. It will only take effect when used with `reset`
- schema:
- type: boolean
- example: true
- default: true
- - name: usageLimit
- in: query
- description: >-
- Indicates the usageLimit whose tracking is being set to 0. Cannot be
- used with `reset`. Use either `reset` or `usageLimit`, not both.
-
- **IMPORTANT:** if the user with `userId` is subscribed to multiple services that share the same name to an usage limit, this endpoint will reset all of them.
+ - name: userId
+ in: path
+ required: true
schema:
type: string
- example: maxAssistantsPerMeeting
+ requestBody:
+ $ref: '#/components/requestBodies/SubscriptionUsageLevelsUpdate'
responses:
'200':
- description: Contract updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Subscription'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
+ description: Usage levels reset
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: object
+
/contracts/{userId}/userContact:
put:
+ summary: Update user contact information
+ description: |
+ Updates the contact information stored in a contract.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can update contact information in any organization
+
+ **Organization Permission**:
+ - ALL scope: can update contact information in the organization
+ - MANAGEMENT scope: can update contact information in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Updates the user contact information of contract
- description: >-
- Performs a novation to update some, or all, fields within the
- `userContact` of a user's contract.
- operationId: updateContractUserContactByUserId
parameters:
- - $ref: '#/components/parameters/UserId'
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
requestBody:
$ref: '#/components/requestBodies/SubscriptionUserContactNovation'
responses:
'200':
- description: Contract updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Subscription'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
+ description: Contact updated
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: object
+
/contracts/{userId}/billingPeriod:
put:
+ summary: Update billing period
+ description: |
+ Updates the billing period configuration for a contract.
+
+ **Authentication**: User API Key (ADMIN) | Organization API Key
+
+ **User Permission**:
+ - ADMIN user: can update billing period in any organization
+
+ **Organization Permission**:
+ - ALL scope: can update billing period in the organization
+ - MANAGEMENT scope: can update billing period in the organization
+
tags:
- - contracts
+ - Contracts
security:
- ApiKeyAuth: []
- summary: Updates the user billing period information from contract
- description: >-
- Performs a novation to update some, or all, fields within the
- `billingPeriod` of a user's contract.
- operationId: updateContractBillingPeriodByUserId
parameters:
- - $ref: '#/components/parameters/UserId'
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
requestBody:
$ref: '#/components/requestBodies/SubscriptionBillingNovation'
responses:
'200':
- description: Contract updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Subscription'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
+ description: Billing period updated
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: object
+
/features:
get:
+ summary: List available features
+ description: |
+ Retrieves all features available in the system for evaluation.
+
+ **Authentication**: Organization API Key
+
+ **Permission**: Org Key with any scope (ALL/MANAGEMENT/EVALUATION)
+
tags:
- - features
+ - Features
security:
- ApiKeyAuth: []
- summary: Retrieves all the features of the SaaS
- description: >-
- Retrieves all features configured within the SaaS, along with their
- service and pricing version
- operationId: getFeatures
parameters:
- name: featureName
in: query
- description: >-
- Name of feature to filter
required: false
schema:
type: string
- example: meetings
+ description: Filter features by name (case-insensitive regex match)
- name: serviceName
in: query
- description: >-
- Name of service to filter features
required: false
schema:
type: string
- example: zoom
+ description: Filter features by service name (case-insensitive regex match)
- name: pricingVersion
in: query
- description: >-
- Pricing version to filter features
required: false
schema:
type: string
- example: 2024
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/Offset'
- - $ref: '#/components/parameters/Limit'
- - $ref: '#/components/parameters/Order'
+ description: Filter features by pricing version
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 1
+ description: |
+ Page number for pagination (1-based).
+ Ignored when offset is greater than 0.
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 0
+ description: |
+ Number of features to skip (0-based offset).
+ When offset is greater than 0, it overrides the page parameter.
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 20
+ description: Maximum number of features to return per page
- name: sort
in: query
- description: Field name to sort the results by.
required: false
schema:
type: string
- enum:
- - featureName
- - serviceName
- example: featureName
+ enum: [featureName, serviceName]
+ description: Field to sort features by
+ - name: order
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [asc, desc]
+ default: asc
+ description: Sort order (ascending or descending)
- name: show
in: query
- description: Indicates whether to list features from active pricings only, archived ones, or both.
required: false
schema:
type: string
- enum:
- - active
- - archived
- - all
- default: active
+ enum: [active, archived, all]
+ description: Filter features by status (active, archived, or all)
responses:
'200':
- description: Successful operation
+ description: List of available features
content:
application/json:
schema:
type: array
items:
- $ref: '#/components/schemas/FeatureToToggle'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ $ref: '#/components/schemas/Feature'
+
/features/{userId}:
post:
+ summary: Evaluate features for user
+ description: |
+ Evaluates which features are available for a specific user based on their
+ subscription contract and pricing plan.
+
+ **Authentication**: Organization API Key only
+
+ **Permission**: Org Key with any scope
+
tags:
- - features
+ - Features
security:
- ApiKeyAuth: []
- summary: Evaluates all features within the services contracted by a user.
- description: >-
- **IMPORTANT:** This operation does not update the usage levels of the user’s subscription. Therefore, it should not be used to authorize server-side operations that may modify those levels. If you choose to use it anyway for this purpose, make sure to manually update the corresponding usage limits—although this is not the recommended approach.
- operationId: evaluateAllFeaturesByUserId
parameters:
- - $ref: '#/components/parameters/UserId'
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
- name: details
in: query
- description: >-
- Whether to include detailed evaluation results. Check the Schema
- view of the 200 response to see both types of response
required: false
schema:
type: boolean
default: false
+ description: Return detailed feature evaluation information including limits and consumption
- name: server
in: query
- description: >-
- Whether to consider server expression for evaluation.
required: false
schema:
type: boolean
default: false
+ description: Perform evaluation using server-side context instead of token-based evaluation
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
responses:
'200':
- description: Successful operation
- content:
- application/json:
- schema:
- oneOf:
- - $ref: '#/components/schemas/SimpleFeaturesEvaluationResult'
- - $ref: '#/components/schemas/DetailedFeaturesEvaluationResult'
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- default:
- description: Unexpected error
+ description: Feature evaluation results
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
+ type: object
+ properties:
+ features:
+ type: array
+ items:
+ type: object
+
/features/{userId}/pricing-token:
post:
+ summary: Generate pricing token
+ description: |
+ Generates a JWT token containing the user's pricing and subscription information
+ for use by client-side feature evaluation.
+
+ **Authentication**: Organization API Key only
+
+ **Permission**: Org Key with any scope
+
tags:
- - features
+ - Features
security:
- ApiKeyAuth: []
- summary: Generates a pricing-token for a given user
- description: >-
- Retrieves the result of the evaluation of all the features regarding the
- contract of the user identified with userId and generates a
- Pricing-Token with such information.
-
-
- **WARNING:** In order to create the token, both the configured envs
- JWT_SECRET and JWT_EXPIRATION will be used.
-
-
- **IMPORTANT:** This operation does not update the usage levels of the user’s subscription. Therefore, it should not be used to authorize server-side operations that may modify those levels. If you choose to use it anyway for this purpose, make sure to manually update the corresponding usage limits—although this is not the recommended approach.
- operationId: evaluateAllFeaturesByUserIdAndGeneratePricingToken
parameters:
- - $ref: '#/components/parameters/UserId'
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
- name: server
in: query
- description: >-
- Whether to consider server expression for evaluation.
required: false
schema:
type: boolean
- example: false
default: false
+ description: Perform evaluation using server-side context instead of token-based evaluation
responses:
'200':
- description: >-
- Successful operation (You can go to [jwt.io](https://jwt.io) to
- check its payload)
+ description: Pricing token generated
content:
application/json:
schema:
type: object
properties:
- pricingToken:
+ token:
type: string
- example: >-
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmZWF0dXJlcyI6eyJtZWV0aW5ncyI6eyJldmFsIjp0cnVlLCJsaW1pdCI6eyJtYXhBc3Npc3RhbnRzUGVyTWVldGluZyI6MTAwfSwidXNlZCI6eyJtYXhBc3Npc3RhbnRzUGVyTWVldGluZyI6MTB9fSwiYXV0b21hdGllZENhcHRpb25zIjp7ImV2YWwiOmZhbHNlLCJsaW1pdCI6W10sInVzZWQiOltdfX0sInN1YiI6ImowaG5EMDMiLCJleHAiOjE2ODc3MDU5NTEsInN1YnNjcmlwdGlvbkNvbnRleHQiOnsibWF4QXNzaXN0YW50c1Blck1lZXRpbmciOjEwfSwiaWF0IjoxNjg3NzA1ODY0LCJjb25maWd1cmF0aW9uQ29udGV4dCI6eyJtZWV0aW5ncyI6eyJkZXNjcmlwdGlvbiI6Ikhvc3QgYW5kIGpvaW4gcmVhbC10aW1lIHZpZGVvIG1lZXRpbmdzIHdpdGggSEQgYXVkaW8sIHNjcmVlbiBzaGFyaW5nLCBjaGF0LCBhbmQgY29sbGFib3JhdGlvbiB0b29scy4gU2NoZWR1bGUgb3Igc3RhcnQgbWVldGluZ3MgaW5zdGFudGx5LCB3aXRoIHN1cHBvcnQgZm9yIHVwIHRvIFggcGFydGljaXBhbnRzIGRlcGVuZGluZyBvbiB5b3VyIHBsYW4uIiwidmFsdWVUeXBlIjoiQk9PTEVBTiIsImRlZmF1bHRWYWx1ZSI6ZmFsc2UsInZhbHVlIjp0cnVlLCJ0eXBlIjoiRE9NQUlOIiwiZXhwcmVzc2lvbiI6ImNvbmZpZ3VyYXRpb25Db250ZXh0W21lZXRpbmdzXSAmJiBhcHBDb250ZXh0W251bWJlck9mUGFydGljaXBhbnRzXSA8IHN1YnNjcmlwdGlvbkNvbnRleHRbbWF4UGFydGljaXBhbnRzXSIsInNlcnZlckV4cHJlc3Npb24iOiJjb25maWd1cmF0aW9uQ29udGV4dFttZWV0aW5nc10gJiYgYXBwQ29udGV4dFtudW1iZXJPZlBhcnRpY2lwYW50c10gPD0gc3Vic2NyaXB0aW9uQ29udGV4dFttYXhQYXJ0aWNpcGFudHNdIiwicmVuZGVyIjoiQVVUTyJ9fX0.w3l-A1xrlBS_dd_NS8mUVdOvpqCbjxXEePxP1RqtS2k
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ description: JWT token with pricing information
+
/features/{userId}/{featureId}:
post:
+ summary: Evaluate single feature
+ description: |
+ Evaluates a specific feature for a user with expected consumption, returning
+ whether the feature is allowed and any usage warnings.
+
+ **Authentication**: Organization API Key only
+
+ **Permission**: Org Key with any scope
+
tags:
- - features
+ - Features
security:
- ApiKeyAuth: []
- summary: Evaluates a feature for a given user
- description: >-
- Retrieves the result of the evaluation of the feature identified by
- featureId regarding the contract of the user identified with userId
- operationId: evaluateFeatureByIdAndUserId
parameters:
- - $ref: '#/components/parameters/UserId'
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
- name: featureId
in: path
- description: The id of the feature that is going to be evaluated
required: true
schema:
type: string
- example: zoom-meetings
- name: server
in: query
- description: >-
- Whether to consider server expression for evaluation.
required: false
schema:
type: boolean
default: false
+ description: Perform evaluation using server-side context instead of token-based evaluation
- name: revert
in: query
- description: >-
- Indicates whether to revert an optimistic usage update performed during a previous evaluation.
-
-
- **IMPORTANT:** Reversions are only effective if the original optimistic update occurred within the last 2 minutes.
required: false
schema:
type: boolean
default: false
+ description: Reset feature usage levels after evaluation
- name: latest
in: query
- description: >-
- Indicates whether the revert operation must reset the usage level to the most recent cached value (true) or to the oldest available one (false). Must be used with `revert`, otherwise it will not make any effect.
required: false
schema:
type: boolean
default: false
+ description: Use the latest feature configuration
requestBody:
- description: >-
- Optionally, you can provide the expected usage consumption for all relevant limits during the evaluation. This enables the optimistic mode of the evaluation engine, meaning you won’t need to notify SPACE afterward about the actual consumption from your host application — SPACE will automatically assume the provided usage was consumed.
-
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ expectedConsumption:
+ type: number
+ example: 1.5
+ responses:
+ '200':
+ description: Feature evaluation result
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ allowed:
+ type: boolean
+ reason:
+ type: string
+
+ /analytics/api-calls:
+ get:
+ summary: Get API call statistics
+ description: |
+ Retrieves statistics about API call usage in the system.
+
+ **Authentication**: User API Key (ADMIN, USER) | Organization API Key
+
+ **User Permission**:
+ - USER user: can view API call statistics
+ - ADMIN user: can view API call statistics
+
+ **Organization Permission**:
+ - ALL scope: can view API call statistics
+ - MANAGEMENT scope: can view API call statistics
+ - EVALUATION scope: can view API call statistics
+
+ tags:
+ - Analytics
+ security:
+ - ApiKeyAuth: []
+ responses:
+ '200':
+ description: API call statistics
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ total:
+ type: integer
+ byEndpoint:
+ type: object
+
+ /analytics/evaluations:
+ get:
+ summary: Get feature evaluation statistics
+ description: |
+ Retrieves statistics about feature evaluations performed by the system.
+
+ **Authentication**: User API Key (ADMIN, USER) | Organization API Key
+
+ **User Permission**:
+ - USER user: can view feature evaluation statistics
+ - ADMIN user: can view feature evaluation statistics
- The body must be a Map whose keys are usage limits names (only those that participate in the evaluation of the feature will be considered), and values are the expected consumption for them.
-
-
- If you provide expected consumption values for only a subset of the usage limits involved in the feature evaluation — but not all — the evaluation will fail. In other words, you either provide **all** expected consumptions or **none** at all.
+ **Organization Permission**:
+ - ALL scope: can view feature evaluation statistics
+ - MANAGEMENT scope: can view feature evaluation statistics
+ - EVALUATION scope: can view feature evaluation statistics
+ tags:
+ - Analytics
+ security:
+ - ApiKeyAuth: []
+ responses:
+ '200':
+ description: Evaluation statistics
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ totalEvaluations:
+ type: integer
+ byFeature:
+ type: object
+
+ /cache/get:
+ get:
+ summary: Get cached value
+ description: |
+ Retrieves a value from the system cache by key.
+
+ **Authentication**: User API Key (ADMIN only)
+
+ **Permission**: ADMIN role required
+
+ tags:
+ - Cache
+ security:
+ - ApiKeyAuth: []
+ parameters:
+ - name: key
+ in: query
+ required: true
+ schema:
+ type: string
+ example: pricing_cache_zoom
+ responses:
+ '200':
+ description: Cached value retrieved
+ content:
+ application/json:
+ schema:
+ type: object
+
+ /cache/set:
+ post:
+ summary: Set cache value
+ description: |
+ Stores a value in the system cache with the specified key.
- **IMPORTANT:** SPACE will only update the user’s usage levels if the feature evaluation returns true.
+ **Authentication**: User API Key (ADMIN only)
+ **Permission**: ADMIN role required
- **WARNING:** Supplying expected usage is not required. However, when the consumption is known in advance — for example, the size of a file to be stored in cloud storage — it’s strongly recommended to include it to improve performance.
+ tags:
+ - Cache
+ security:
+ - ApiKeyAuth: []
+ requestBody:
+ required: true
content:
application/json:
schema:
type: object
required:
- - userContact
- - subscriptionPlans
- - subscriptionAddOns
- additionalProperties:
- type: integer
- example: 20
- example:
- storage: 50
- apiCalls: 1
- bandwidth: 20
+ - key
+ - value
+ properties:
+ key:
+ type: string
+ example: pricing_cache_zoom
+ value:
+ type: object
responses:
'200':
- description: Successful operation
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/DetailedFeatureEvaluationResult'
- '204':
- description: Successful operation
- content:
- application/json:
- schema:
- type: string
- example: Usage level reset successfully
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- '404':
- description: Contract not found
- '422':
- $ref: '#/components/responses/UnprocessableEntity'
- default:
- description: Unexpected error
+ description: Value cached successfully
content:
application/json:
schema:
- $ref: '#/components/schemas/Error'
- /analytics/api-calls:
+ type: object
+
+ /events/status:
get:
+ summary: Get event service status
+ description: |
+ Checks if the WebSocket event service is running and operational.
+
+ **Authentication**: Public (no API key required)
+
tags:
- - analytics
- security:
- - ApiKeyAuth: []
- summary: Retrieves the daily number of API calls processed by SPACE during the last 7 days.
- description: >-
- Retrieves the daily number of API calls processed by SPACE during the last 7 days.
- operationId: getAnalyticsApiCalls
+ - Events
responses:
'200':
- description: Successful operation
+ description: Service status
content:
application/json:
schema:
type: object
properties:
- labels:
- type: array
- description: >-
- Array of days of the week for which the data is provided.
- The last element corresponds to the most recent day.
- items:
- type: string
- format: dayOfWeek
- description: Day of the week of the corresponding value from the `data` array.
- example: 'Mon'
- example: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
- data:
- type: array
- description: >-
- Array of integers representing the number of API calls
- processed by SPACE on each day of the week. The last
- element corresponds to the most recent day.
- items:
- type: integer
- description: Number of API calls processed on that date
- example: 1500
- example: [1234, 6565, 2959, 6498, 11020, 1700, 190, 2950]
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- default:
- description: Unexpected error
+ status:
+ type: string
+ example: The WebSocket event service is active
+
+ # /events/test-event:
+ # post:
+ # summary: Send test event (for testing)
+ # description: |
+ # Sends a test event to all connected WebSocket clients (used for testing purposes).
+
+ # **Authentication**: Public
+
+ # tags:
+ # - Events
+ # requestBody:
+ # required: true
+ # content:
+ # application/json:
+ # schema:
+ # type: object
+ # required:
+ # - serviceName
+ # - pricingVersion
+ # properties:
+ # serviceName:
+ # type: string
+ # example: Zoom
+ # pricingVersion:
+ # type: string
+ # example: "1.0"
+ # responses:
+ # '200':
+ # description: Event sent successfully
+ # content:
+ # application/json:
+ # schema:
+ # type: object
+ # properties:
+ # success:
+ # type: boolean
+ # message:
+ # type: string
+
+ /events/client:
+ get:
+ summary: Get WebSocket client HTML
+ description: |
+ Returns an HTML file for testing WebSocket connections to the event service.
+
+ **Authentication**: Public
+
+ tags:
+ - Events
+ responses:
+ '200':
+ description: WebSocket client HTML
content:
- application/json:
+ text/html:
schema:
- $ref: '#/components/schemas/Error'
- /analytics/evaluations:
+ type: string
+
+ /healthcheck:
get:
+ summary: Service health check
+ description: |
+ Verifies that the SPACE API service is operational and responsive.
+ Use this endpoint for load balancer health checks.
+
+ **Authentication**: Public (no API key required)
+
tags:
- - analytics
- security:
- - ApiKeyAuth: []
- summary: Retrieves the daily number of feature evaluations processed by SPACE during the last 7 days.
- description: >-
- Retrieves the daily number of feature evaluations processed by SPACE during the last 7 days.
- operationId: getAnalyticsEvaluations
+ - Healthcheck
responses:
'200':
- description: Successful operation
+ description: Service is operational
content:
application/json:
schema:
type: object
properties:
- labels:
- type: array
- description: >-
- Array of days of the week for which the data is provided.
- The last element corresponds to the most recent day.
- items:
- type: string
- format: dayOfWeek
- description: Day of the week of the corresponding value from the `data` array.
- example: 'Mon'
- example: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
- data:
- type: array
- description: >-
- Array of integers representing the number of feature evaluations
- processed by SPACE on each day of the week. The last
- element corresponds to the most recent day.
- items:
- type: integer
- description: Number of feature evaluations processed on that date
- example: 1500
- example: [1234, 6565, 2959, 6498, 11020, 1700, 190, 2950]
- '401':
- description: Authentication required
- '403':
- description: Forbidden
- default:
- description: Unexpected error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
+ message:
+ type: string
+ example: Service is up and running!
+
components:
schemas:
Username:
@@ -1598,28 +3626,57 @@ components:
example: johndoe
minLength: 3
maxLength: 30
+
Password:
type: string
description: Password of the user
example: j0hnD03
minLength: 5
+
Role:
type: string
description: Role of the user
- enum:
- - ADMIN
- - MANAGER
- - EVALUATOR
- example: EVALUATOR
- User:
+ enum: [ADMIN, USER]
+ example: USER
+
+ ApiKey:
+ type: string
+ description: Random 32 byte string encoded in hexadecimal
+ example: usr_9cedd24632167a021667df44a26362dfb778c1566c3d4564e132cb58770d8c67
+ pattern: '^usr_[a-f0-9]{64}$'
+ readOnly: true
+
+ Error:
type: object
properties:
- username:
- $ref: '#/components/schemas/Username'
- apiKey:
- $ref: '#/components/schemas/ApiKey'
- role:
- $ref: '#/components/schemas/Role'
+ error:
+ type: string
+ required:
+ - error
+
+ FieldValidationError:
+ type: object
+ properties:
+ type:
+ type: string
+ example: field
+ msg:
+ type: string
+ example: Password must be a string
+ path:
+ type: string
+ example: password
+ location:
+ type: string
+ example: body
+ value:
+ example: invalid
+ required:
+ - type
+ - msg
+ - path
+ - location
+
UserInput:
type: object
properties:
@@ -1632,6 +3689,7 @@ components:
required:
- username
- password
+
UserUpdate:
type: object
properties:
@@ -1641,720 +3699,119 @@ components:
$ref: '#/components/schemas/Password'
role:
$ref: '#/components/schemas/Role'
- Service:
+ User:
+ type: object
+ properties:
+ username:
+ $ref: '#/components/schemas/Username'
+ apiKey:
+ $ref: '#/components/schemas/ApiKey'
+ role:
+ $ref: '#/components/schemas/Role'
+ createdAt:
+ type: string
+ format: date-time
+
+ Organization:
type: object
properties:
id:
- description: Identifier of the service within MongoDB
- $ref: '#/components/schemas/ObjectId'
+ type: string
+ example: 507f1f77bcf86cd799439012
name:
type: string
- description: The name of the service
- example: Zoom
- activePricings:
- type: object
- description: >-
- Map of pricing versions that are used in the service operation, i.e. there are users subscribed to it. Keys are pricing versions, and values are objects that provide information to locate the details of such versions. Such details can be located either with the "id" by which the database stores the version information, or by the "url" through which the details can be found in an external server (such as SPHERE)
- additionalProperties:
+ example: ACME Corporation
+ owner:
+ type: string
+ example: john_doe
+ default:
+ type: boolean
+ example: false
+ members:
+ type: array
+ items:
type: object
properties:
- id:
- $ref: '#/components/schemas/ObjectId'
- url:
+ username:
type: string
- format: path
- example: 'https://sphere.score.us.es/static/collections/63f74bf8eeed64058364b52e/IEEE TSC 2025/zoom/2025.yml'
- archivedPricings:
- type: object
- description: >-
- Map of pricing versions that are used in the service operation, i.e. there are users subscribed to it. Keys are pricing versions, and values are objects that provide information to locate the details of such versions. Such details can be located either with the "id" by which the database stores the version information, or by the "url" through which the details can be found in an external server (such as SPHERE)
- additionalProperties:
+ role:
+ type: string
+ enum: [OWNER, ADMIN, MANAGER, EVALUATOR]
+ apiKeys:
+ type: array
+ items:
type: object
properties:
- id:
- $ref: '#/components/schemas/ObjectId'
- url:
+ key:
type: string
- format: path
- example: 'https://sphere.score.us.es/static/collections/63f74bf8eeed64058364b52e/IEEE TSC 2025/zoom/2025.yml'
- Pricing:
+ scope:
+ type: string
+ enum: [ALL, MANAGEMENT, EVALUATION]
+ createdAt:
+ type: string
+ format: date-time
+
+ Service:
type: object
properties:
- version:
+ name:
type: string
- description: Indicates the version of the pricing
- example: 1.0.0
- currency:
+ example: Zoom
+ description:
type: string
- description: Currency in which pricing's prices are displayed
- enum:
- - AED
- - AFN
- - ALL
- - AMD
- - ANG
- - AOA
- - ARS
- - AUD
- - AWG
- - AZN
- - BAM
- - BBD
- - BDT
- - BGN
- - BHD
- - BIF
- - BMD
- - BND
- - BOB
- - BOV
- - BRL
- - BSD
- - BTN
- - BWP
- - BYN
- - BZD
- - CAD
- - CDF
- - CHE
- - CHF
- - CHW
- - CLF
- - CLP
- - CNY
- - COP
- - COU
- - CRC
- - CUC
- - CUP
- - CVE
- - CZK
- - DJF
- - DKK
- - DOP
- - DZD
- - EGP
- - ERN
- - ETB
- - EUR
- - FJD
- - FKP
- - GBP
- - GEL
- - GHS
- - GIP
- - GMD
- - GNF
- - GTQ
- - GYD
- - HKD
- - HNL
- - HRK
- - HTG
- - HUF
- - IDR
- - ILS
- - INR
- - IQD
- - IRR
- - ISK
- - JMD
- - JOD
- - JPY
- - KES
- - KGS
- - KHR
- - KMF
- - KPW
- - KRW
- - KWD
- - KYD
- - KZT
- - LAK
- - LBP
- - LKR
- - LRD
- - LSL
- - LYD
- - MAD
- - MDL
- - MGA
- - MKD
- - MMK
- - MNT
- - MOP
- - MRU
- - MUR
- - MVR
- - MWK
- - MXN
- - MXV
- - MYR
- - MZN
- - NAD
- - NGN
- - NIO
- - NOK
- - NPR
- - NZD
- - OMR
- - PAB
- - PEN
- - PGK
- - PHP
- - PKR
- - PLN
- - PYG
- - QAR
- - RON
- - RSD
- - RUB
- - RWF
- - SAR
- - SBD
- - SCR
- - SDG
- - SEK
- - SGD
- - SHP
- - SLE
- - SLL
- - SOS
- - SRD
- - SSP
- - STN
- - SVC
- - SYP
- - SZL
- - THB
- - TJS
- - TMT
- - TND
- - TOP
- - TRY
- - TTD
- - TWD
- - TZS
- - UAH
- - UGX
- - USD
- - USN
- - UYI
- - UYU
- - UYW
- - UZS
- - VED
- - VES
- - VND
- - VUV
- - WST
- - XAF
- - XAG
- - XAU
- - XBA
- - XBB
- - XBC
- - XBD
- - XCD
- - XDR
- - XOF
- - XPD
- - XPF
- - XPT
- - XSU
- - XTS
- - XUA
- - XXX
- - YER
- - ZAR
- - ZMW
- - ZWL
- example: USD
- createdAt:
+ example: Video conferencing platform
+ organization:
type: string
- format: date
- description: >-
- The date on which the pricing started its operation. It must be
- specified as a string in the ISO 8601 format (yyyy-mm-dd)
- example: '2025-04-18'
- features:
- type: array
- items:
- $ref: '#/components/schemas/Feature'
- usageLimits:
+ available:
+ type: boolean
+ pricings:
type: array
items:
- $ref: '#/components/schemas/UsageLimit'
+ $ref: '#/components/schemas/Pricing'
+ createdAt:
+ type: string
+ format: date-time
+
+ Pricing:
+ type: object
+ properties:
+ version:
+ type: string
+ example: "1.0.0"
+ service:
+ type: string
+ available:
+ type: boolean
plans:
type: array
items:
- $ref: '#/components/schemas/Plan'
+ type: object
addOns:
type: array
items:
- $ref: '#/components/schemas/AddOn'
- NamedEntity:
- type: object
- properties:
- name:
- type: string
- description: Name of the entity
- example: meetings
- description:
- type: string
- description: Description of the entity
- example: >-
- Host and join real-time video meetings with HD audio, screen
- sharing, chat, and collaboration tools. Schedule or start meetings
- instantly, with support for up to X participants depending on your
- plan.
- required: ['name']
- Feature:
- allOf:
- - $ref: '#/components/schemas/NamedEntity'
- - type: object
- required:
- - valueType
- - defaultValue
- - type
- properties:
- valueType:
- type: string
- enum:
- - BOOLEAN
- - NUMERIC
- - TEXT
- example: BOOLEAN
- defaultValue:
- oneOf:
- - type: boolean
- - type: number
- - type: string
- description: >-
- This field holds the default value of your feature. All default
- values are shared in your plan and addons. You can override your
- features values in plans..features or in
- addOns..features section of your pricing.
-
-
- Supported **payment methods** are: *CARD*, *GATEWAY*, *INVOICE*,
- *ACH*, *WIRE_TRANSFER* or *OTHER*.
-
-
- Check for more information at the offial [Pricing2Yaml
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#featuresnamedefaultvalue.
- example: false
- value:
- oneOf:
- - type: boolean
- - type: number
- - type: string
- description: >-
- The actual value of the feature that is going to be used in the
- evaluation. This will be inferred during evaluations.
- example: true
- type:
- type: string
- description: >-
- Indicates the type of the features. If either `INTEGRATION`,
- `AUTOMATION` or `GUARANTEE` are selected, it's necesary to add some
- extra fields to the feature.
-
-
- For more information about other fields required if one of the above
- is selected, please refer to the [official UML iPricing
- diagram](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/understanding/iPricing).
-
-
- For more information about when to use each type, please refer to
- the [official
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#featuresnametype)
- enum:
- - INFORMATION
- - INTEGRATION
- - DOMAIN
- - AUTOMATION
- - MANAGEMENT
- - GUARANTEE
- - SUPPORT
- - PAYMENT
- example: DOMAIN
- integrationType:
- type: string
- description: >-
- Specifies the type of integration that an `INTEGRATION` feature
- offers.
-
-
- For more information about when to use each integrationType, please
- refer to the [official
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#featuresnameintegrationtype).
- enum:
- - API
- - EXTENSION
- - IDENTITY_PROVIDER
- - WEB_SAAS
- - MARKETPLACE
- - EXTERNAL_DEVICE
- pricingUrls:
- type: array
- description: >-
- If feature `type` is *INTEGRATION* and `integrationType` is
- *WEB_SAAS* this field is **required**.
-
-
- Specifies a list of URLs linking to the associated pricing page of
- third party integrations that you offer in your pricing.
- items:
- type: string
- automationType:
- type: string
- description: >-
- Specifies the type of automation that an `AUTOMATION` feature
- offers.
-
-
- For more information about when to use each automationType, please
- refer to the [official
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#featuresnameautomationtype).
- enum:
- - BOT
- - FILTERING
- - TRACKING
- - TASK_AUTOMATION
- paymentType:
- type: string
- description: Specifies the type of payment allowed by a `PAYMENT` feature.
- enum:
- - CARD
- - GATEWAY
- - INVOICE
- - ACH
- - WIRE_TRANSFER
- - OTHER
- docUrl:
- type: string
- description: |-
- If feature `type` is *GUARANTEE* this is **required**,
-
- URL redirecting to the guarantee or compliance documentation.
- expression:
- type: string
- description: >-
- The expression that is going to be evaluated in order to determine
- wheter a feature is active for the user performing the request or
- not. By default, this expression will be used to resolve evaluations
- unless `serverExpression` is defined.
- example: >-
- configurationContext[meetings] && appContext[numberOfParticipants]
- <= subscriptionContext[maxParticipants]
- serverExpression:
- type: string
- description: >-
- Configure a different expression to be evaluated only on the server
- side.
- render:
- type: string
- description: >-
- Choose the behaviour when displaying the feature of the pricing. Use
- this feature in the [Pricing2Yaml
- editor](https://sphere.score.us.es/editor).
- enum:
- - AUTO
- - DISABLED
- - ENABLED
- UsageLimit:
- allOf:
- - $ref: '#/components/schemas/NamedEntity'
- - type: object
- required:
- - valueType
- - defaultValue
- - type
- properties:
- valueType:
- type: string
- enum:
- - BOOLEAN
- - NUMERIC
- example: NUMERIC
- defaultValue:
- oneOf:
- - type: boolean
- - type: number
- description: >-
- This field holds the default value of your usage limit. All default
- values are shared in your plan and addons. You can override your
- usage limits values in plans..usageLimits or in
- addOns..usageLimits section of your pricing.
-
-
- Check for more information at the offial [Pricing2Yaml
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#usagelimitsnamedefaultvalue).
- example: 30
- value:
- oneOf:
- - type: boolean
- - type: number
- - type: string
- description: >-
- The actual value of the usage limit that is going to be used in the
- evaluation. This will be inferred during evaluations regaring the
- user's subscription.
- example: 100
- type:
- type: string
- description: >-
- Indicates the type of the usage limit.
-
-
- - If set to RENEWABLE, the usage limit will be tracked by
- subscriptions by default.
-
- - If set to NON_RENEWABLE, the usage limit will only be tracked if
- `trackable` == true
-
-
- For more information about when to use each type, please refer to
- the [official
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#usagelimitsnametype)
- enum:
- - RENEWABLE
- - NON_RENEWABLE
- example: RENEWABLE
- trackable:
- type: boolean
- description: >-
- Determines wether an usage limit must be tracked within the
- subscription state or not.
-
-
- If the `type` is set to *NON_RENEWABLE*, this field is **required**.
- default: false
- period:
- $ref: '#/components/schemas/Period'
- Plan:
- allOf:
- - $ref: '#/components/schemas/NamedEntity'
- - type: object
- required:
- - price
- - features
- properties:
- price:
- type: number
- description: The price of the plan
- example: 5
- private:
- type: boolean
- description: Determines wether the plan can be contracted by anyone or not
- example: false
- default: false
- features:
- type: object
- description: >-
- A map containing the values of features whose default value must be
- replaced. Keys are feature names and values will replace feture's
- default value.
- additionalProperties:
- oneOf:
- - type: boolean
- example: true
- - type: string
- example: ALLOWED
- description: >-
- The value that will be considered in evaluations for users that
- subscribe to the plan.
- usageLimits:
- type: object
- description: >-
- A map containing the values of usage limits that must be replaced.
- Keys are usage limit names and values will replace usage limit's
- default value.
- additionalProperties:
- oneOf:
- - type: boolean
- example: true
- - type: number
- example: 1000
- description: >-
- The value that will be considered in evaluations for users that
- subscribe to the plan.
- AddOn:
- allOf:
- - $ref: '#/components/schemas/NamedEntity'
- - type: object
- required:
- - price
- properties:
- private:
- type: boolean
- description: Determines wether the add-on can be contracted by anyone or not
- example: false
- default: false
- price:
- type: number
- description: The price of the add-on
- example: 15
- availableFor:
- type: array
- description: >-
- Indicates that your add-on is available to purchase only if the user
- is subscribed to any of the plans indicated in this list. If the
- field is not provided, the add-on will be available for all plans.
-
-
- For more information, please refer to the [Pricing2Yaml
- documentation](https://pricing4saas-docs.vercel.app/docs/2.0.1/api/Pricing2Yaml/pricing2yaml-v30-specification#addonsnameavailablefor)
- items:
- type: string
- example:
- - BASIC
- - PRO
- dependsOn:
- type: array
- description: >-
- A list of add-on to which the user must be subscribed in order to
- purchase the current addon.
-
-
- For example: Imagine that an addon A depends on add-on B. This means
- that in order to include in your subscription the add-on A you also
- have to include the add-on B.
-
-
- Therefore, you can subscribe to B or to A and B; but not exclusively
- to A.
- items:
- type: string
- example:
- - phoneDialing
- excludes:
- type: array
- description: >-
- A list of add-on to which the user cannot be subscribed in order to
- purchase the current addon.
-
-
- For example: Imagine that an addon A excludes on add-on B. This
- means that in order to include A in a subscription, B cannot be
- contracted.
-
-
- Therefore, you can subscribe to either A or be B; but not to both.
- items:
- type: string
- example:
- - phoneDialing
- features:
- type: object
- description: >-
- A map containing the values of features that must be replaced. Keys
- are feature names and values will replace those defined by plans.
- additionalProperties:
- oneOf:
- - type: boolean
- example: true
- - type: string
- example: ALLOWED
- description: >-
- The value that will be considered in evaluations for users that
- subscribe to the add-on.
- usageLimits:
type: object
- description: >-
- A map containing the values of usage limits that must be replaced.
- Keys are usage limits names and values will replace those defined by
- plans
- additionalProperties:
- oneOf:
- - type: boolean
- example: true
- - type: number
- example: 1000
- description: >-
- The value that will be considered in evaluations for users that
- subscribe to the add-on.
- usageLimitsExtensions:
- type: object
- description: >-
- A map containing the values of usage limits that must be extended.
- Keys are usageLimits names and values will extend those defined by
- plans.
- additionalProperties:
- type: number
- description: >-
- The value that will be added to the 'base' of the subscription in
- order to increase the limit considered in evaluations. For
- example: if usage limit A's base value is 10, and an add-on
- extends it by 10, then evaluations will consider 20 as the value
- of the usage limit'
- example: 1000
- subscriptionConstraints:
- type: object
- description: >-
- Defines some restrictions that must be taken into consideration
- before creating a subscription.
- properties:
- minQuantity:
- type: integer
- description: >-
- Indicates the minimum amount of times that an add-on must be
- contracted in order to be included within a subscription.
- example: 1
- default: 1
- maxQuantity:
- type: integer
- description: >-
- Indicates the maximum amount of times that an add-on must be
- contracted in order to be included within a subscription.
- example: null
- default: null
- quantityStep:
- type: integer
- description: >-
- Specifies the required purchase block size for this add-on. The
- `amount` included within the subscription for this add-on must
- be a multiple of this value.
- example: 1
- default: 1
- Period:
- type: object
- description: >-
- Defines a period of time after which either a *RENEWABLE* usage limit or
- a subscription billing must be reset.
- properties:
- value:
- type: integer
- description: The amount of time that defines the period.
- example: 1
- default: 1
- unit:
+ createdAt:
type: string
- description: The unit of time to be considered when defining the period
- enum:
- - SEC
- - MIN
- - HOUR
- - DAY
- - MONTH
- - YEAR
- example: MONTH
- default: MONTH
- Subscription:
+ format: date-time
+
+ Contract:
type: object
- description: >-
- Defines an iSubscription, which is a computational representation of the
- actual state and history of a subscription contracted by an user.
+ description: |
+ Contract representation compatible with iSubscription.
required:
- billingPeriod
+ - organizationId
- usageLevels
- contractedServices
- subscriptionPlans
- - hystory
+ - history
properties:
id:
$ref: '#/components/schemas/ObjectId'
+ organizationId:
+ type: string
+ description: Organization identifier owning the contract
+ example: 507f1f77bcf86cd799439012
userContact:
$ref: '#/components/schemas/UserContact'
billingPeriod:
@@ -2370,56 +3827,8 @@ components:
history:
type: array
items:
- $ref: '#/components/schemas/SubscriptionSnapshot'
- BillingPeriod:
- type: object
- required:
- - startDate
- - endDate
- properties:
- startDate:
- description: >-
- The date on which the current billing period started
- $ref: '#/components/schemas/Date'
- example: '2025-04-18T00:00:00Z'
- endDate:
- description: >-
- The date on which the current billing period is expected to end or
- to be renewed
- example: '2025-12-31T00:00:00Z'
- $ref: '#/components/schemas/Date'
- autoRenew:
- type: boolean
- description: >-
- Determines whether the current billing period will be extended
- `renewalDays` days once it ends (true), or if the subcription will
- be cancelled by that point (false).
- example: true
- default: true
- renewalDays:
- $ref: '#/components/schemas/RenewalDays'
- ContractedService:
- type: object
- description: >-
- Map where the keys are names of services that must match with the value
- of the `saasName` field within the serialized pricing indicated in their
- `path`
- additionalProperties:
- type: string
- format: path
- description: >-
- Specifies the version of the service's pricing to which the user is subscribed.
-
-
- **WARNING:** The selected pricing must be marked as **active**
- within the service.
- example:
- zoom: "2025"
- petclinic: "2024"
- UserId:
- type: string
- description: The id of the contract of the user
- example: 01c36d29-0d6a-4b41-83e9-8c6d9310c508
+ $ref: '#/components/schemas/SubscriptionSnapshot'
+
UserContact:
type: object
required:
@@ -2431,8 +3840,10 @@ components:
description: The id of the contract of the user
example: 01c36d29-0d6a-4b41-83e9-8c6d9310c508
username:
- $ref: '#/components/schemas/Username'
- fistName:
+ type: string
+ description: Username of the user
+ example: johndoe
+ firstName:
type: string
description: The first name of the user
example: John
@@ -2448,41 +3859,57 @@ components:
type: string
description: The phone number of the user, with international code
example: +34 666 666 666
+
+ BillingPeriod:
+ type: object
+ required:
+ - startDate
+ - endDate
+ properties:
+ startDate:
+ description: The date on which the current billing period started
+ $ref: '#/components/schemas/Date'
+ example: '2025-04-18T00:00:00Z'
+ endDate:
+ description: The date on which the current billing period is expected to end or to be renewed
+ $ref: '#/components/schemas/Date'
+ example: '2025-12-31T00:00:00Z'
+ autoRenew:
+ type: boolean
+ description: Determines whether the current billing period will be extended renewalDays days once it ends
+ example: true
+ default: true
+ renewalDays:
+ $ref: '#/components/schemas/RenewalDays'
+
+ ContractedService:
+ type: object
+ description: Map where keys are service names and values are pricing versions
+ additionalProperties:
+ type: string
+ format: path
+ description: Pricing version to which the user is subscribed
+ example:
+ zoom: "2025"
+ petclinic: "2024"
+
SubscriptionPlans:
type: object
- description: >-
- Map where the keys are names of contractedService whose plan is going to
- be included within the subscription.
+ description: Map of service name to plan name
additionalProperties:
type: string
- description: >-
- The plan selected to be included within the subscription from the
- pricing of the service indicated in `contractedService`
minLength: 1
example:
zoom: ENTERPRISE
petclinic: GOLD
+
SubscriptionAddons:
type: object
- description: >-
- Map where the keys are names of contractedService whose add-ons are
- going to be included within the subscription.
+ description: Map of service name to add-ons and quantities
additionalProperties:
type: object
- description: >-
- Map where keys are the names of the add-ons selected to be included
- within the subscription from the pricing of the service indicated in
- `contractedService` and values determine how many times they have been
- contracted. They must be consistent with the **availability,
- dependencies, exclusions and subscription contstraints** established
- in the pricing.
additionalProperties:
type: integer
- description: >-
- Indicates how many times has the add-on been contracted within the
- subscription. This number must be within the range defined by the
- `subscriptionConstraints` of the add-on
- example: 1
minimum: 0
example:
zoom:
@@ -2490,24 +3917,44 @@ components:
hugeMeetings: 1
petclinic:
petsAdoptionCentre: 1
+
+ UsageLevels:
+ type: object
+ description: Usage levels for contracted services
+ additionalProperties:
+ type: object
+ additionalProperties:
+ type: object
+ required:
+ - consumed
+ properties:
+ resetTimestamp:
+ description: Reset timestamp (only for renewable usage limits)
+ $ref: '#/components/schemas/Date'
+ consumed:
+ type: number
+ description: Consumed quota for the usage limit
+ example: 5
+ example:
+ zoom:
+ maxSeats:
+ consumed: 10
+ petclinic:
+ maxPets:
+ consumed: 2
+ maxVisits:
+ consumed: 5
+ resetTimestamp: "2025-07-31T00:00:00Z"
+
SubscriptionSnapshot:
type: object
properties:
startDate:
- description: >-
- The date on which the user started using the subscription snapshot
- example: '2024-04-18T00:00:00Z'
+ description: The date on which the user started using the snapshot
$ref: '#/components/schemas/Date'
+ example: '2024-04-18T00:00:00Z'
endDate:
- description: >-
- The date on which the user finished using the subscription snapshot,
- either because the contract suffered a novation, i.e. the
- subscription plan/add-ons or the pricing version to which the
- contract is referred changed; or the user cancelled his subcription.
-
-
- It must be specified as a string in the ISO 8601 format
- (yyyy-mm-dd).
+ description: The date on which the user finished using the snapshot
$ref: '#/components/schemas/Date'
example: '2024-04-17T00:00:00Z'
contractedServices:
@@ -2516,289 +3963,44 @@ components:
$ref: '#/components/schemas/SubscriptionPlans'
subscriptionAddOns:
$ref: '#/components/schemas/SubscriptionAddons'
- FeatureToToggle:
- type: object
- properties:
- info:
- $ref: '#/components/schemas/Feature'
- service:
- type: string
- description: The name of the service which includes the feature
- example: Zoom
- pricingVersion:
- type: string
- description: The version of the service's pricing where you can find the feature
- example: 2.0.0
- DetailedFeatureEvaluationResult:
- type: object
- properties:
- used:
- type: object
- description: >-
- Map whose keys indicate the name of all usage limits tracked by the
- subscription that have participated in the evaluation of the
- feature, and their values indicates the current quota consumption of
- the user for each one.
- additionalProperties:
- type: number
- description: >-
- Value indicating the quota consumed of this usage limit by the
- user
- example: 10
- example:
- storage: 50
- apiCalls: 1
- bandwidth: 20
- limit:
- type: object
- description: >-
- Map whose keys indicate the name of all usage limits tracked by the
- subscription that have participated in the evaluation of the
- feature, and their values indicates the current quota limit of the
- user for each one.
- additionalProperties:
- type: number
- description: >-
- Value indicating the quota limit of this usage limit regarding the
- user contract
- example: 100
- example:
- storage: 500
- apiCalls: 1000
- bandwidth: 200
- eval:
- type: boolean
- description: >-
- Result indicating whether the feature with the given featureId is
- active (true) or not (false) for the given user
- error:
- type: object
- description: test
- properties:
- code:
- type: string
- description: Code to identify the error
- enum:
- - EVALUATION_ERROR
- - FLAG_NOT_FOUND
- - GENERAL
- - INVALID_EXPECTED_CONSUMPTION
- - PARSE_ERROR
- - TYPE_MISMATCH
- example: FLAG_NOT_FOUND
- message:
- type: string
- description: Message of the error
- SimpleFeaturesEvaluationResult:
- type: object
- description: >-
- Map whose keys indicate the name of all features that have been
- evaluated and its values indicates the result of such evaluation.
- additionalProperties:
- type: boolean
- description: >-
- Result indicating whether the feature with the given featureId is
- active (true) or not (false) for the given user
- example: true
- example:
- meetings: true
- automatedCaptions: true
- phoneDialing: false
- DetailedFeaturesEvaluationResult:
- type: object
- description: >-
- Map whose keys indicate the name of all features that have been
- evaluated and its values indicates the detailed result of such
- evaluation.
- additionalProperties:
- type: object
- properties:
- used:
- type: object
- description: >-
- Map whose keys indicate the name of all usage limits tracked by
- the subscription that have participated in the evaluation of the
- feature, and their values indicates the current quota consumption
- of the user for each one.
- additionalProperties:
- type: number
- description: >-
- Value indicating the quota consumed of this usage limit by the
- user
- example: 10
- example:
- storage: 5
- apiCalls: 13
- bandwidth: 2
- limit:
- type: object
- description: >-
- Map whose keys indicate the name of all usage limits tracked by
- the subscription that have participated in the evaluation of the
- feature, and their values indicates the current quota limit of the
- user for each one.
- additionalProperties:
- type: number
- description: >-
- Value indicating the quota limit of this usage limit regarding
- the user contract
- example: 100
- example:
- storage: 500
- apiCalls: 100
- bandwidth: 300
- eval:
- type: boolean
- description: >-
- Result indicating whether the feature with the given featureId is
- active (true) or not (false) for the given user
- example: true
- error:
- type: object
- description: test
- properties:
- code:
- type: string
- description: Code to identify the error
- enum:
- - EVALUATION_ERROR
- - FLAG_NOT_FOUND
- - GENERAL
- - INVALID_EXPECTED_CONSUMPTION
- - PARSE_ERROR
- - TYPE_MISMATCH
- example: FLAG_NOT_FOUND
- message:
- type: string
- description: Message of the error
- Error:
- type: object
- properties:
- error:
- type: string
- required:
- - error
- FieldValidationError:
- type: object
- properties:
- type:
- type: string
- example: field
- msg:
- type: string
- example: Password must be a string
- path:
- type: string
- example: password
- location:
- type: string
- example: body
- value:
- example: 1
- required:
- - type
- - msg
- - path
- - location
- ApiKey:
+
+ Date:
type: string
- description: Random 32 byte string encoded in hexadecimal
- example: 0051e657dd30bc3a07583c20dcadc627211624ae8bf39acf05f08a3fdf2b434c
- pattern: '^[a-f0-9]{64}$'
- readOnly: true
+ format: date-time
+ description: Date in UTC
+ example: '2025-12-31T00:00:00Z'
+
ObjectId:
type: string
description: ObjectId of the corresponding MongoDB document
example: 68050bd09890322c57842f6f
pattern: '^[a-f0-9]{24}$'
readOnly: true
- Date:
- type: string
- format: date-time
- description: Date in UTC
- example: '2025-12-31T00:00:00Z'
+
RenewalDays:
type: integer
- description: >-
- If `autoRenew` == true, this field is **required**.
-
- It represents the number of days by which the current billing period
- will be extended once it reaches its `endDate`. When this extension
- operation is performed, the endDate is replaced by `endDate` +
- `renewalDays`.
+ description: Number of days by which the billing period will be extended
example: 365
default: 30
minimum: 1
- UsageLevels:
- type: object
- description: >-
- Map that contains information about the current usage levels of the
- trackable usage limits of the contracted services. These usage limits are:
- - All **RENEWABLE** usage limits.
- - **NON_RENEWABLE** usage limits with `trackable` == true
-
- Keys are service names and values are Maps containing the usage levels
- of each service.
- additionalProperties:
- type: object
- description: >-
- Map that contains information about the current usage levels of the
- usage limits that must be tracked.
+ Feature:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ example: Premium Support
+ description:
+ type: string
+ service:
+ type: string
- Keys are usage limit names and values contain the current state of each
- usage level and their expected resetTimestamp (if usage limit type is RENEWABLE)
- additionalProperties:
- type: object
- required:
- - consumed
- properties:
- resetTimestamp:
- description: >-
- The date on which the current consumption of the usage limit
- is expected to be reset, i.e. set to 0.
-
- If the usage limit is **NON_RENEWABLE**, this field must not
- be set.
-
- It must be specified as a string in UTC
- $ref: '#/components/schemas/Date'
- consumed:
- type: number
- description: >-
- Indicates how much quota has been consumed for this usage limit
- example: 5
- example:
- zoom:
- maxSeats:
- consumed: 10
- petclinic:
- maxPets:
- consumed: 2
- maxVisits:
- consumed: 5
- resetTimestamp: "2025-07-31T00:00:00Z"
requestBodies:
- SubscriptionCompositionNovation:
- description: >-
- Novates the composition of an existent contract, triggering a state
- update
- content:
- application/json:
- schema:
- type: object
- required:
- - subscriptionPlans
- - subscriptionAddOns
- properties:
- contractedServices:
- $ref: '#/components/schemas/ContractedService'
- subscriptionPlans:
- $ref: '#/components/schemas/SubscriptionPlans'
- subscriptionAddOns:
- $ref: '#/components/schemas/SubscriptionAddons'
SubscriptionCreation:
- description: Creates a new subscription within Pricing4SaaS
+ description: Creates a new subscription within SPACE
+ required: true
content:
application/json:
schema:
@@ -2816,10 +4018,7 @@ components:
properties:
autoRenew:
type: boolean
- description: >-
- Determines whether the current billing period will be
- extended `renewalDays` days once it ends (true), or if the
- subcription will be cancelled by that point (false).
+ description: Whether the billing period auto-renews
example: true
default: true
renewalDays:
@@ -2830,65 +4029,119 @@ components:
$ref: '#/components/schemas/SubscriptionPlans'
subscriptionAddOns:
$ref: '#/components/schemas/SubscriptionAddons'
+ example:
+ userContact:
+ userId: 01c36d29-0d6a-4b41-83e9-8c6d9310c508
+ username: johndoe
+ firstName: John
+ lastName: Doe
+ email: john.doe@my-domain.com
+ billingPeriod:
+ autoRenew: true
+ renewalDays: 30
+ contractedServices:
+ zoom: "2025"
+ petclinic: "2024"
+ subscriptionPlans:
+ zoom: ENTERPRISE
+ petclinic: GOLD
+ subscriptionAddOns:
+ zoom:
+ extraSeats: 2
+ petclinic:
+ petsAdoptionCentre: 1
+
+ SubscriptionCompositionNovation:
+ description: Novates the composition of an existent contract
required: true
- SubscriptionUserContactNovation:
- description: |-
- Updates the contact information of a user from his contract
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - subscriptionPlans
+ - subscriptionAddOns
+ properties:
+ contractedServices:
+ $ref: '#/components/schemas/ContractedService'
+ subscriptionPlans:
+ $ref: '#/components/schemas/SubscriptionPlans'
+ subscriptionAddOns:
+ $ref: '#/components/schemas/SubscriptionAddons'
+ example:
+ contractedServices:
+ zoom: "2025"
+ petclinic: "2024"
+ subscriptionPlans:
+ zoom: PRO
+ petclinic: GOLD
+ subscriptionAddOns:
+ zoom:
+ extraSeats: 1
- **IMPORTANT:** **userId** not needed in the request body
+ SubscriptionUserContactNovation:
+ description: Updates the contact information of a user from their contract
+ required: true
content:
application/json:
schema:
type: object
properties:
- fistName:
+ firstName:
type: string
- description: The first name of the user
example: John
lastName:
type: string
- description: The last name of the user
example: Doe
email:
type: string
- description: The email of the user
example: john.doe@my-domain.com
username:
- $ref: '#/components/schemas/Username'
+ type: string
+ example: johndoe
phone:
type: string
- description: The phone number of the user, with international code
example: +34 666 666 666
- required: true
- SubscriptionBillingNovation:
- description: >-
- Updates the billing information from a contract.
-
- **IMPORTANT:** It is not needed to provide all the fields within the
- request body. Only fields sent will be replaced.
+ SubscriptionBillingNovation:
+ description: Updates the billing information from a contract
+ required: true
content:
application/json:
schema:
type: object
properties:
endDate:
- description: >-
- The date on which the current billing period is expected to end or
- to be renewed
- example: '2025-12-31T00:00:00Z'
$ref: '#/components/schemas/Date'
autoRenew:
type: boolean
- description: >-
- Determines whether the current billing period will be extended
- `renewalDays` days once it ends (true), or if the subcription will
- be cancelled by that point (false).
example: true
default: true
renewalDays:
$ref: '#/components/schemas/RenewalDays'
+ example:
+ endDate: '2025-12-31T00:00:00Z'
+ autoRenew: true
+ renewalDays: 30
+
+ SubscriptionUsageLevelsUpdate:
+ description: Updates usage levels for a contract
required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties:
+ type: object
+ additionalProperties:
+ type: number
+ example:
+ zoom:
+ maxSeats: 10
+ petclinic:
+ maxPets: 2
+ maxVisits: 5
+
responses:
UnprocessableEntity:
description: Request sent could not be processed properly
@@ -2901,11 +4154,7 @@ components:
type: array
items:
$ref: '#/components/schemas/FieldValidationError'
- securitySchemes:
- ApiKeyAuth:
- type: apiKey
- in: header
- name: x-api-key
+
parameters:
Username:
name: username
@@ -2913,69 +4162,14 @@ components:
required: true
schema:
$ref: '#/components/schemas/Username'
- ServiceName:
- name: serviceName
- in: path
- description: Name of service to return
- required: true
- schema:
- type: string
- example: Zoom
- PricingVersion:
- name: pricingVersion
- in: path
- description: Pricing version that is going to be updated
- required: true
- schema:
- type: string
- example: 1.0.0
- UserId:
- name: userId
- in: path
- description: The id of the user for locating the contract
- required: true
- schema:
- $ref: '#/components/schemas/UserId'
- Offset:
- name: offset
- in: query
- description: |-
- Number of items to skip before starting to collect the result set.
- Cannot be used with `page`. Use either `page` or `offset`, not both.
- required: false
- schema:
- type: integer
- minimum: 0
- Page:
- name: page
- in: query
- description: >-
- Page number to retrieve, starting from 1. Cannot be used with
- `offset`. Use either `page` or `offset`, not both.
- required: false
- schema:
- type: integer
- minimum: 1
- default: 1
- Limit:
- name: limit
- in: query
- description: Maximum number of items to return. Useful to control pagination size.
- required: false
- schema:
- type: integer
- minimum: 1
- maximum: 100
- default: 20
- example: 20
- Order:
- name: order
- in: query
- description: Sort direction. Use `asc` for ascending or desc`` for descending.
- required: false
- schema:
- type: string
- enum:
- - asc
- - desc
- default: asc
+
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: x-api-key
+ description: |
+ API Key for authentication. Two types are supported:
+ - **User API Key** (format: `usr_*`): Obtained from POST /users/authenticate endpoint
+ - **Organization API Key** (format: `org_*`): Created in organization settings
+
diff --git a/api/package.json b/api/package.json
index 4f1c4f5..147e6db 100644
--- a/api/package.json
+++ b/api/package.json
@@ -5,7 +5,7 @@
"main": "src/main/index.ts",
"type": "module",
"scripts": {
- "dev": "tsx src/main/index.ts",
+ "dev": "tsx --watch src/main/index.ts",
"dev:setup": "cd docker/dev && docker compose up -d --build",
"dev:setup:test": "pnpm run generate-test-env && pnpm run dev:setup && tsx scripts/seedMongodb.ts",
"test": "chmod +x run-tests.sh && ./run-tests.sh",
@@ -56,6 +56,7 @@
"mongo-seeding": "^4.0.2",
"mongoose": "^8.14.0",
"multer": "1.4.5-lts.2",
+ "nock": "^14.0.10",
"node-fetch": "^3.3.2",
"pricing4ts": "^0.10.3",
"redis": "^4.7.0",
diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml
index 2fc296f..082c3c9 100644
--- a/api/pnpm-lock.yaml
+++ b/api/pnpm-lock.yaml
@@ -56,6 +56,9 @@ importers:
multer:
specifier: 1.4.5-lts.2
version: 1.4.5-lts.2
+ nock:
+ specifier: ^14.0.10
+ version: 14.0.10
node-fetch:
specifier: ^3.3.2
version: 3.3.2
@@ -386,6 +389,10 @@ packages:
'@mongodb-js/saslprep@1.2.2':
resolution: {integrity: sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==}
+ '@mswjs/interceptors@0.39.8':
+ resolution: {integrity: sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==}
+ engines: {node: '>=18'}
+
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@@ -402,6 +409,15 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@open-draft/deferred-promise@2.2.0':
+ resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
+
+ '@open-draft/logger@0.3.0':
+ resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
+
+ '@open-draft/until@2.1.0':
+ resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+
'@openfeature/core@1.8.0':
resolution: {integrity: sha512-FX/B6yMD2s4BlMKtB0PqSMl94eLaTwh0VK9URcMvjww0hqMOeGZnGv4uv9O5E58krAan7yCOCm4NBCoh2IATqw==}
@@ -1375,6 +1391,9 @@ packages:
resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
engines: {node: '>= 0.4'}
+ is-node-process@1.2.0:
+ resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
+
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
@@ -1432,6 +1451,9 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ json-stringify-safe@5.0.1:
+ resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
+
jwt-simple@0.5.6:
resolution: {integrity: sha512-40aUybvhH9t2h71ncA1/1SbtTNCVZHgsTsTgqPUxGWDmUDrXyDf2wMNQKEbdBjbf4AI+fQhbECNTV6lWxQKUzg==}
engines: {node: '>= 0.4.0'}
@@ -1616,6 +1638,10 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
+ nock@14.0.10:
+ resolution: {integrity: sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==}
+ engines: {node: '>=18.20.0 <20 || >=20.12.1'}
+
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -1671,6 +1697,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
+ outvariant@1.4.3:
+ resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
+
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -1762,6 +1791,10 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+ propagate@2.0.1:
+ resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==}
+ engines: {node: '>= 8'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -1936,6 +1969,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ strict-event-emitter@0.5.1:
+ resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2431,6 +2467,15 @@ snapshots:
dependencies:
sparse-bitfield: 3.0.3
+ '@mswjs/interceptors@0.39.8':
+ dependencies:
+ '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/logger': 0.3.0
+ '@open-draft/until': 2.1.0
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ strict-event-emitter: 0.5.1
+
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5':
@@ -2445,6 +2490,15 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
+ '@open-draft/deferred-promise@2.2.0': {}
+
+ '@open-draft/logger@0.3.0':
+ dependencies:
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+
+ '@open-draft/until@2.1.0': {}
+
'@openfeature/core@1.8.0': {}
'@openfeature/server-sdk@1.18.0(@openfeature/core@1.8.0)':
@@ -3519,6 +3573,8 @@ snapshots:
call-bind: 1.0.8
define-properties: 1.2.1
+ is-node-process@1.2.0: {}
+
is-number@7.0.0: {}
is-regex@1.2.1:
@@ -3577,6 +3633,8 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
+ json-stringify-safe@5.0.1: {}
+
jwt-simple@0.5.6: {}
kareem@2.6.3: {}
@@ -3748,6 +3806,12 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
+ nock@14.0.10:
+ dependencies:
+ '@mswjs/interceptors': 0.39.8
+ json-stringify-safe: 5.0.1
+ propagate: 2.0.1
+
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -3812,6 +3876,8 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
+ outvariant@1.4.3: {}
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -3891,6 +3957,8 @@ snapshots:
process-nextick-args@2.0.1: {}
+ propagate@2.0.1: {}
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -4133,6 +4201,8 @@ snapshots:
streamsearch@1.1.0: {}
+ strict-event-emitter@0.5.1: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
diff --git a/api/scripts/pricingJsonFormatter.ts b/api/scripts/pricingJsonFormatter.ts
index 06f6584..c9035b7 100644
--- a/api/scripts/pricingJsonFormatter.ts
+++ b/api/scripts/pricingJsonFormatter.ts
@@ -52,6 +52,7 @@ interface AddOn {
interface Pricing {
_id: { $oid: string };
_serviceName: string;
+ _organizationId: string;
version: string;
currency: string;
createdAt: string;
diff --git a/api/scripts/seedMongodb.ts b/api/scripts/seedMongodb.ts
index 7a4dc63..c249b8b 100644
--- a/api/scripts/seedMongodb.ts
+++ b/api/scripts/seedMongodb.ts
@@ -1,3 +1,7 @@
+import mongoose from 'mongoose';
import {seedDatabase} from '../src/main/database/seeders/mongo/seeder';
+import { getMongoDBConnectionURI } from '../src/main/config/mongoose';
-await seedDatabase();
\ No newline at end of file
+await mongoose.connect(getMongoDBConnectionURI());
+await seedDatabase();
+await mongoose.disconnect();
\ No newline at end of file
diff --git a/api/src/main/app.ts b/api/src/main/app.ts
index f167d63..8780329 100644
--- a/api/src/main/app.ts
+++ b/api/src/main/app.ts
@@ -1,43 +1,45 @@
-import * as dotenv from "dotenv";
-import express, {Application} from "express";
-import type { Server } from "http";
-import type { AddressInfo } from "net";
+import * as dotenv from 'dotenv';
+import express, { Application } from 'express';
+import type { Server } from 'http';
+import type { AddressInfo } from 'net';
-import container from "./config/container";
-import { disconnectMongoose, initMongoose } from "./config/mongoose";
-import { initRedis } from "./config/redis";
-import { seedDatabase } from "./database/seeders/mongo/seeder";
-import loadGlobalMiddlewares from "./middlewares/GlobalMiddlewaresLoader";
-import routes from "./routes/index";
-import { seedDefaultAdmin } from "./database/seeders/common/userSeeder";
+import container from './config/container';
+import { disconnectMongoose, initMongoose } from './config/mongoose';
+import { initRedis } from './config/redis';
+import { seedDatabase } from './database/seeders/mongo/seeder';
+import loadGlobalMiddlewares from './middlewares/GlobalMiddlewaresLoader';
+import routes from './routes/index';
+import { seedDefaultAdmin } from './database/seeders/common/userSeeder';
-const green = "\x1b[32m";
-const blue = "\x1b[36m";
-const reset = "\x1b[0m";
-const bold = "\x1b[1m";
+const green = '\x1b[32m';
+const blue = '\x1b[36m';
+const reset = '\x1b[0m';
+const bold = '\x1b[1m';
-const initializeApp = async () => {
+const initializeApp = async (seedDatabase: boolean = true) => {
dotenv.config();
const app: Application = express();
loadGlobalMiddlewares(app);
await routes(app);
- await initializeDatabase();
+ await initializeDatabase(seedDatabase);
const redisClient = await initRedis();
- if (["development", "testing"].includes(process.env.ENVIRONMENT ?? "")) {
+ if (['development', 'testing'].includes(process.env.ENVIRONMENT ?? '')) {
await redisClient.sendCommand(['FLUSHALL']);
console.log(`${green}➜${reset} ${bold}Redis cache cleared.${reset}`);
}
- container.resolve("cacheService").setRedisClient(redisClient);
+ container.resolve('cacheService').setRedisClient(redisClient);
// await postInitializeDatabase(app)
return app;
};
-const initializeServer = async (): Promise<{
+const initializeServer = async (
+ seedDatabase: boolean = true
+): Promise<{
server: Server;
app: Application;
}> => {
- const app: Application = await initializeApp();
- const port = 3000;
+ const app: Application = await initializeApp(seedDatabase);
+ const port = 3000;
// Using a promise to ensure the server is started before returning it
const server: Server = await new Promise((resolve, reject) => {
@@ -50,16 +52,16 @@ const initializeServer = async (): Promise<{
const addressInfo: AddressInfo = server.address() as AddressInfo;
// Inicializar el servicio de eventos con el servidor HTTP
- container.resolve("eventService").initialize(server);
+ container.resolve('eventService').initialize(server);
console.log(
- ` ${green}➜${reset} ${bold}API:${reset} ${blue}http://localhost${addressInfo.port !== 80 ? `:${bold}${addressInfo.port}${reset}/` : "/"}`
+ ` ${green}➜${reset} ${bold}API:${reset} ${blue}http://localhost${addressInfo.port !== 80 ? `:${bold}${addressInfo.port}${reset}/` : '/'}`
);
console.log(
- ` ${green}➜${reset} ${bold}WebSockets:${reset} ${blue}ws://localhost${addressInfo.port !== 80 ? `:${bold}${addressInfo.port}${reset}/events/pricings` : "/events/pricings"}`
+ ` ${green}➜${reset} ${bold}WebSockets:${reset} ${blue}ws://localhost${addressInfo.port !== 80 ? `:${bold}${addressInfo.port}${reset}/events/pricings` : '/events/pricings'}`
);
- if (["development", "testing"].includes(process.env.ENVIRONMENT ?? "")) {
+ if (['development', 'testing'].includes(process.env.ENVIRONMENT ?? '')) {
console.log(`${green}➜${reset} ${bold}Loaded Routes:${reset}`);
app._router.stack
.filter((layer: any) => layer.route)
@@ -71,20 +73,24 @@ const initializeServer = async (): Promise<{
return { server, app };
};
-const initializeDatabase = async () => {
+const initializeDatabase = async (seedDatabaseFlag: boolean = true) => {
let connection;
try {
- switch (process.env.DATABASE_TECHNOLOGY ?? "mongoDB") {
- case "mongoDB":
+ switch (process.env.DATABASE_TECHNOLOGY ?? 'mongoDB') {
+ case 'mongoDB':
connection = await initMongoose();
- if (["development", "testing"].includes(process.env.ENVIRONMENT ?? "")) {
- await seedDatabase();
- }else{
- await seedDefaultAdmin();
+ if (['development'].includes(process.env.ENVIRONMENT ?? '')) {
+ if (seedDatabaseFlag) {
+ await seedDatabase();
+ }
+ } else {
+ if (seedDatabaseFlag) {
+ await seedDefaultAdmin();
+ }
}
break;
default:
- throw new Error("Unsupported database technology");
+ throw new Error('Unsupported database technology');
}
} catch (error) {
console.error(error);
@@ -94,16 +100,16 @@ const initializeDatabase = async () => {
const disconnectDatabase = async () => {
try {
- switch (process.env.DATABASE_TECHNOLOGY ?? "mongoDB") {
- case "mongoDB":
+ switch (process.env.DATABASE_TECHNOLOGY ?? 'mongoDB') {
+ case 'mongoDB':
await disconnectMongoose();
break;
default:
- throw new Error("Unsupported database technology");
+ throw new Error('Unsupported database technology');
}
} catch (error) {
console.error(error);
}
};
-export { disconnectDatabase,initializeServer };
+export { disconnectDatabase, initializeServer };
diff --git a/api/src/main/config/container.ts b/api/src/main/config/container.ts
index aa082ac..d984be7 100644
--- a/api/src/main/config/container.ts
+++ b/api/src/main/config/container.ts
@@ -9,6 +9,7 @@ import MongooseUserRepository from "../repositories/mongoose/UserRepository";
import MongoosePricingRepository from "../repositories/mongoose/PricingRepository";
import MongooseContractRepository from "../repositories/mongoose/ContractRepository";
import MongooseAnalyticsRepository from "../repositories/mongoose/AnalyticsRepository";
+import MongooseOrganizationRepository from "../repositories/mongoose/OrganizationRepository";
import CacheService from "../services/CacheService";
import ServiceService from "../services/ServiceService";
@@ -17,18 +18,20 @@ import ContractService from "../services/ContractService";
import FeatureEvaluationService from "../services/FeatureEvaluationService";
import EventService from "../services/EventService";
import AnalyticsService from "../services/AnalyticsService";
+import OrganizationService from "../services/OrganizationService";
dotenv.config();
function initContainer(databaseType: string): AwilixContainer {
const container: AwilixContainer = createContainer();
- let userRepository, serviceRepository, pricingRepository, contractRepository, analyticsRepository;
+ let userRepository, serviceRepository, pricingRepository, contractRepository, organizationRepository, analyticsRepository;
switch (databaseType) {
case "mongoDB":
userRepository = new MongooseUserRepository();
serviceRepository = new MongooseServiceRepository();
pricingRepository = new MongoosePricingRepository();
contractRepository = new MongooseContractRepository();
+ organizationRepository = new MongooseOrganizationRepository();
analyticsRepository = new MongooseAnalyticsRepository();
break;
default:
@@ -39,6 +42,7 @@ function initContainer(databaseType: string): AwilixContainer {
serviceRepository: asValue(serviceRepository),
pricingRepository: asValue(pricingRepository),
contractRepository: asValue(contractRepository),
+ organizationRepository: asValue(organizationRepository),
analyticsRepository: asValue(analyticsRepository),
userService: asClass(UserService).singleton(),
serviceService: asClass(ServiceService).singleton(),
@@ -47,6 +51,7 @@ function initContainer(databaseType: string): AwilixContainer {
analyticsService: asClass(AnalyticsService).singleton(),
featureEvaluationService: asClass(FeatureEvaluationService).singleton(),
eventService: asClass(EventService).singleton(),
+ organizationService: asClass(OrganizationService).singleton(),
});
return container;
}
diff --git a/api/src/main/config/mongoose.ts b/api/src/main/config/mongoose.ts
index 900ce58..947dc93 100644
--- a/api/src/main/config/mongoose.ts
+++ b/api/src/main/config/mongoose.ts
@@ -27,6 +27,8 @@ const getMongoDBConnectionURI = () => {
const wholeUri = process.env.MONGO_URI || `mongodb://${dbCredentials}localhost:27017/${databaseName}?authSource=${databaseName}`;
+ console.log("Using MongoDB URI: ", wholeUri);
+
return wholeUri;
}
diff --git a/api/src/main/config/permissions.ts b/api/src/main/config/permissions.ts
new file mode 100644
index 0000000..bc04643
--- /dev/null
+++ b/api/src/main/config/permissions.ts
@@ -0,0 +1,269 @@
+/**
+ * Permission configuration for API routes
+ *
+ * This file defines access control rules for both User API Keys and Organization API Keys.
+ *
+ * Pattern matching:
+ * - '*' matches any single path segment
+ * - '**' matches any number of path segments (must be at the end)
+ *
+ * Examples:
+ * - '/users/*' matches '/users/john' but not '/users/john/profile'
+ * - '/organizations/**' matches '/organizations/org1', '/organizations/org1/services', etc.
+ */
+
+import { RoutePermission } from "../types/permissions";
+
+
+/**
+ * Route permission configuration
+ *
+ * Rules are evaluated in order. The first matching rule determines access.
+ * If no rule matches, access is denied by default.
+ */
+export const ROUTE_PERMISSIONS: RoutePermission[] = [
+ // ============================================
+ // User Management Routes (User API Keys ONLY)
+ // ============================================
+ {
+ path: '/users/authenticate',
+ methods: ['POST'],
+ isPublic: true,
+ },
+ {
+ path: '/users',
+ methods: ['POST'],
+ isPublic: true,
+ },
+ {
+ path: '/users/**',
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ requiresUser: true, // Organization API keys cannot access user routes
+ },
+
+ // ============================================
+ // Service Management Routes (Organization-scoped)
+ // User API Keys can access via /organizations/:organizationId/services/**
+ // ============================================
+ {
+ path: '/organizations/*/services',
+ methods: ['GET', 'POST', 'DELETE'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ requiresUser: true,
+ },
+ {
+ path: '/organizations/*/services/*',
+ methods: ['GET', 'PUT', 'PATCH', 'DELETE'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ requiresUser: true,
+ },
+ {
+ path: '/organizations/*/services/*/pricings',
+ methods: ['GET', 'POST'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ requiresUser: true,
+ },
+ {
+ path: '/organizations/*/services/*/pricings/*',
+ methods: ['GET', 'PUT', 'PATCH', 'DELETE'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ requiresUser: true,
+ },
+
+ // ============================================
+ // Contract Management Routes (Organization-scoped)
+ // User API Keys can access via /organizations/:organizationId/contracts/**
+ // ============================================
+ {
+ path: '/organizations/*/contracts',
+ methods: ['GET', 'POST', 'DELETE'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ allowedOrgRoles: [],
+ },
+ {
+ path: '/organizations/*/contracts/*',
+ methods: ['GET', 'PUT', 'PATCH', 'DELETE'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ allowedOrgRoles: [],
+ },
+
+ // ============================================
+ // Organization Management Routes (User API Keys ONLY)
+ // ============================================
+ {
+ path: '/organizations/**',
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ requiresUser: true, // Organization API keys cannot access these routes
+ },
+
+ // ============================================
+ // Service Management Routes (Direct access)
+ // Organization API Keys can access via /services/**
+ // ============================================
+ {
+ path: '/services',
+ methods: ['GET'],
+ allowedUserRoles: ['ADMIN'],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+ {
+ path: '/services',
+ methods: ['POST'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+ {
+ path: '/services',
+ methods: ['DELETE'],
+ allowedUserRoles: ['ADMIN'],
+ allowedOrgRoles: ['ALL'],
+ },
+ {
+ path: '/services/*',
+ methods: ['GET'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+ {
+ path: '/services/*',
+ methods: ['PUT', 'PATCH'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+ {
+ path: '/services/*',
+ methods: ['DELETE'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL'],
+ },
+ {
+ path: '/services/*/pricings',
+ methods: ['GET'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+ {
+ path: '/services/*/pricings',
+ methods: ['POST'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+ {
+ path: '/services/*/pricings/*',
+ methods: ['GET'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+ {
+ path: '/services/*/pricings/*',
+ methods: ['PUT', 'PATCH'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+ {
+ path: '/services/*/pricings/*',
+ methods: ['DELETE'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL'],
+ },
+
+ // ============================================
+ // Contract Routes
+ // ============================================
+ {
+ path: '/contracts',
+ methods: ['GET', 'POST', 'PUT'],
+ allowedUserRoles: ['ADMIN'],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+ {
+ path: '/contracts',
+ methods: ['DELETE'],
+ allowedUserRoles: ['ADMIN'],
+ allowedOrgRoles: ['ALL'],
+ },
+ {
+ path: '/contracts/**',
+ methods: ['GET', 'PUT', 'PATCH'],
+ allowedUserRoles: ['ADMIN'],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+ {
+ path: '/contracts/**',
+ methods: ['DELETE'],
+ allowedUserRoles: ['ADMIN'],
+ allowedOrgRoles: ['ALL'],
+ },
+
+ // ============================================
+ // Feature Evaluation Routes
+ // ============================================
+ {
+ path: '/features/evaluate',
+ methods: ['POST'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+ {
+ path: '/features',
+ methods: ['GET'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+ {
+ path: '/features/**',
+ methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
+ allowedUserRoles: [],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT'],
+ },
+
+ // ============================================
+ // Analytics Routes
+ // ============================================
+ {
+ path: '/analytics/**',
+ methods: ['GET'],
+ allowedUserRoles: ['ADMIN', 'USER'],
+ allowedOrgRoles: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ },
+
+ // ============================================
+ // Cache Routes (Admin Only)
+ // ============================================
+ {
+ path: '/cache/**',
+ methods: ['GET', 'POST'],
+ allowedUserRoles: ['ADMIN'],
+ requiresUser: true,
+ },
+
+ // ============================================
+ // Event Routes (Public)
+ // ============================================
+ {
+ path: '/events/**',
+ methods: ['GET', 'POST'],
+ isPublic: true,
+ },
+
+ // ============================================
+ // Health Check (Public)
+ // ============================================
+ {
+ path: '/healthcheck',
+ methods: ['GET'],
+ isPublic: true, // No authentication required
+ },
+];
+
+/**
+ * Default denial message when no permission is granted
+ */
+export const DEFAULT_PERMISSION_DENIED_MESSAGE = 'You do not have permission to access this resource';
+
+/**
+ * Message when organization API key tries to access user-only routes
+ */
+export const ORG_KEY_USER_ROUTE_MESSAGE = 'This route requires a user API key. Organization API keys are not allowed';
diff --git a/api/src/main/controllers/ContractController.ts b/api/src/main/controllers/ContractController.ts
index 6846614..49dda97 100644
--- a/api/src/main/controllers/ContractController.ts
+++ b/api/src/main/controllers/ContractController.ts
@@ -17,8 +17,10 @@ class ContractController {
this.show = this.show.bind(this);
this.create = this.create.bind(this);
this.novate = this.novate.bind(this);
+ this.novateByGroupId = this.novateByGroupId.bind(this);
this.novateUserContact = this.novateUserContact.bind(this);
this.novateBillingPeriod = this.novateBillingPeriod.bind(this);
+ this.novateBillingPeriodByGroupId = this.novateBillingPeriodByGroupId.bind(this);
this.resetUsageLevels = this.resetUsageLevels.bind(this);
this.prune = this.prune.bind(this);
this.destroy = this.destroy.bind(this);
@@ -28,16 +30,19 @@ class ContractController {
try {
const queryParams = this._transformIndexQueryParams(req.query);
const filters = req.body?.filters;
+ const merged = filters ? { ...queryParams, filters } : queryParams;
+ let organizationId = req.org?.id ?? req.params.organizationId;
- if (filters) {
- // merge filters into top-level params and delegate to index which will call repository.findByFilters
- const merged = { ...queryParams, filters };
- const contracts = await this.contractService.index(merged);
- res.json(contracts);
- return;
+ if (!req.params.organizationId && req.user && req.user.role === 'ADMIN' && !req.org?.id) {
+ organizationId = undefined;
}
- const contracts = await this.contractService.index(queryParams);
+ const [contracts, total] = await Promise.all([
+ this.contractService.index(merged, organizationId),
+ this.contractService.count(merged, organizationId),
+ ]);
+
+ res.set('X-Total-Count', total.toString());
res.json(contracts);
} catch (err: any) {
if (err.message.toLowerCase().includes('validation of query params')) {
@@ -65,13 +70,37 @@ class ContractController {
async create(req: any, res: any) {
try {
const contractData: ContractToCreate = req.body;
+ const authOrganizationId = req.org?.id ?? req.params.organizationId;
+
+ if (
+ contractData.organizationId &&
+ authOrganizationId &&
+ contractData.organizationId !== authOrganizationId &&
+ !(req.user && req.user.role === 'ADMIN')
+ ) {
+ throw new Error('PERMISSION ERROR: Organization ID mismatch');
+ }
+
+
+ if (!(req.user && req.user.role === 'ADMIN')){
+ contractData.organizationId = authOrganizationId;
+ }else{
+ if (!contractData.organizationId) {
+ throw new Error('INVALID DATA: Organization ID is required for contract creation. Since you are an ADMIN, you must specify the organizationId in the request body.');
+ }
+ }
+
const contract = await this.contractService.create(contractData);
res.status(201).json(contract);
} catch (err: any) {
if (err.message.toLowerCase().includes('invalid')) {
res.status(400).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('permission error')) {
+ res.status(403).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('conflict:')) {
+ res.status(409).send({ error: err.message });
} else {
- res.status(500).send({ error: err.message });
+ res.status(500).send({ error: err.message });
}
}
}
@@ -93,6 +122,31 @@ class ContractController {
}
}
+ async novateByGroupId(req: any, res: any) {
+ try {
+ const groupId = req.query.groupId;
+ const organizationId = req.org?.id ?? req.params.organizationId;
+ if (!groupId) {
+ res.status(400).send({ error: 'Missing groupId query parameter' });
+ return;
+ }
+ const newSubscription: Subscription = req.body;
+ const contracts = await this.contractService.novateByGroupId(groupId, organizationId, newSubscription);
+ res.status(200).json(contracts);
+ } catch (err: any) {
+ if (
+ err.message.toLowerCase().includes('not found') ||
+ err.message.toLowerCase().includes('no contracts found')
+ ) {
+ res.status(404).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('invalid subscription:')) {
+ res.status(400).send({ error: err.message });
+ } else {
+ res.status(500).send({ error: err.message });
+ }
+ }
+ }
+
async novateUserContact(req: any, res: any) {
try {
const userId = req.params.userId;
@@ -112,11 +166,41 @@ class ContractController {
try {
const userId = req.params.userId;
const billingPeriod = req.body;
+
const contract = await this.contractService.novateBillingPeriod(userId, billingPeriod);
res.status(200).json(contract);
} catch (err: any) {
if (err.message.toLowerCase().includes('not found')) {
res.status(404).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('invalid data:')) {
+ res.status(400).send({ error: err.message });
+ } else {
+ res.status(500).send({ error: err.message });
+ }
+ }
+ }
+
+ async novateBillingPeriodByGroupId(req: any, res: any) {
+ try {
+ const groupId = req.query.groupId;
+ const organizationId = req.org?.id ?? req.params.organizationId;
+ const billingPeriod = req.body;
+
+ if (!groupId) {
+ res.status(400).send({ error: 'Missing groupId query parameter' });
+ return;
+ }
+
+ const contracts = await this.contractService.novateBillingPeriodByGroupId(groupId, organizationId, billingPeriod);
+ res.status(200).json(contracts);
+ } catch (err: any) {
+ if (
+ err.message.toLowerCase().includes('not found') ||
+ err.message.toLowerCase().includes('no contracts found')
+ ) {
+ res.status(404).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('invalid data:')) {
+ res.status(400).send({ error: err.message });
} else {
res.status(500).send({ error: err.message });
}
@@ -147,11 +231,15 @@ class ContractController {
async prune(req: any, res: any) {
try {
- const result: number = await this.contractService.prune();
+ const organizationId = req.org?.id ?? req.params.organizationId;
+
+ const result: number = await this.contractService.prune(organizationId, req.user);
res.status(204).json({ message: `Deleted ${result} contracts successfully` });
} catch (err: any) {
if (err.message.toLowerCase().includes('not found')) {
res.status(404).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('permission error')) {
+ res.status(403).send({ error: err.message });
} else {
res.status(500).send({ error: err.message });
}
@@ -180,6 +268,7 @@ class ContractController {
firstName: indexQueryParams.firstName as string,
lastName: indexQueryParams.lastName as string,
email: indexQueryParams.email as string,
+ groupId: indexQueryParams.groupId as string,
page: parseInt(indexQueryParams['page'] as string) || 1,
offset: parseInt(indexQueryParams['offset'] as string) || 0,
limit: parseInt(indexQueryParams['limit'] as string) || 20,
diff --git a/api/src/main/controllers/FeatureEvaluationController.ts b/api/src/main/controllers/FeatureEvaluationController.ts
index c057173..889bd82 100644
--- a/api/src/main/controllers/FeatureEvaluationController.ts
+++ b/api/src/main/controllers/FeatureEvaluationController.ts
@@ -16,9 +16,15 @@ class FeatureEvaluationController {
async index(req: any, res: any) {
try {
+ const organizationId = req.org?.id;
+
+ if (!organizationId) {
+ throw new Error('PERMISSION ERROR: This endpoint can only be invoqued with organization authentication');
+ }
+
const queryParams: FeatureIndexQueryParams = this._transformIndexQueryParams(req.query);
- const features = await this.featureEvaluationService.index(queryParams);
+ const features = await this.featureEvaluationService.index(queryParams, organizationId);
res.json(features);
} catch (err: any) {
res.status(500).send({ error: err.message });
@@ -29,13 +35,20 @@ class FeatureEvaluationController {
try {
const userId = req.params.userId;
const options = this._transformEvalQueryParams(req.query);
- const featureEvaluation = await this.featureEvaluationService.eval(userId, options);
+
+ if (!req.org) {
+ throw new Error('PERMISSION ERROR: This endpoint can only be invoqued with organization authentication');
+ }
+
+ const featureEvaluation = await this.featureEvaluationService.eval(userId, req.org, options);
res.json(featureEvaluation);
} catch (err: any) {
if (err.message.toLowerCase().includes('not found')) {
res.status(404).send({ error: err.message });
}else if (err.message.toLowerCase().includes('invalid')) {
res.status(400).send({ error: err.message });
+ }else if (err.message.toLowerCase().includes('permission error')) {
+ res.status(403).send({ error: err.message });
}else {
res.status(500).send({ error: err.message });
}
@@ -46,13 +59,20 @@ class FeatureEvaluationController {
try {
const userId = req.params.userId;
const options = this._transformGenerateTokenQueryParams(req.query);
- const token = await this.featureEvaluationService.generatePricingToken(userId, options);
+
+ if (!req.org) {
+ throw new Error('PERMISSION ERROR: This endpoint can only be invoqued with organization authentication');
+ }
+
+ const token = await this.featureEvaluationService.generatePricingToken(userId, req.org, options);
res.json({pricingToken: token});
} catch (err: any) {
if (err.message.toLowerCase().includes('not found')) {
res.status(404).send({ error: err.message });
}else if (err.message.toLowerCase().includes('invalid')) {
res.status(400).send({ error: err.message });
+ }else if (err.message.toLowerCase().includes('permission error')) {
+ res.status(403).send({ error: err.message });
}else {
res.status(500).send({ error: err.message });
}
@@ -65,7 +85,12 @@ class FeatureEvaluationController {
const featureId = req.params.featureId;
const expectedConsumption = req.body ?? {};
const options = this._transformFeatureEvalQueryParams(req.query);
- const featureEvaluation: boolean | FeatureEvaluationResult = await this.featureEvaluationService.evalFeature(userId, featureId, expectedConsumption, options);
+
+ if (!req.org) {
+ throw new Error('PERMISSION ERROR: This endpoint can only be invoqued with organization authentication');
+ }
+
+ const featureEvaluation: boolean | FeatureEvaluationResult = await this.featureEvaluationService.evalFeature(userId, featureId, expectedConsumption, req.org, options);
if (typeof featureEvaluation === 'boolean') {
res.status(204).json("Usage level reset successfully");
@@ -78,6 +103,8 @@ class FeatureEvaluationController {
res.status(404).send({ error: err.message });
}else if (err.message.toLowerCase().includes('invalid')) {
res.status(400).send({ error: err.message });
+ }else if (err.message.toLowerCase().includes('permission error')) {
+ res.status(403).send({ error: err.message });
}else {
res.status(500).send({ error: err.message });
}
diff --git a/api/src/main/controllers/OrganizationController.ts b/api/src/main/controllers/OrganizationController.ts
new file mode 100644
index 0000000..27923f5
--- /dev/null
+++ b/api/src/main/controllers/OrganizationController.ts
@@ -0,0 +1,320 @@
+import container from '../config/container.js';
+import OrganizationService from '../services/OrganizationService.js';
+
+class OrganizationController {
+ private organizationService: OrganizationService;
+
+ constructor() {
+ this.organizationService = container.resolve('organizationService');
+ this.getAll = this.getAll.bind(this);
+ this.getById = this.getById.bind(this);
+ this.create = this.create.bind(this);
+ this.addMember = this.addMember.bind(this);
+ this.updateMemberRole = this.updateMemberRole.bind(this);
+ this.update = this.update.bind(this);
+ this.addApiKey = this.addApiKey.bind(this);
+ this.removeApiKey = this.removeApiKey.bind(this);
+ this.removeMember = this.removeMember.bind(this);
+ this.delete = this.delete.bind(this);
+ }
+
+ async getAll(req: any, res: any) {
+ try {
+ let organizations;
+ let total = 0;
+
+ // SPACE admins can see all organizations with pagination
+ if (req.user.role === 'ADMIN') {
+ const { q, limit = 10, offset = 0 } = req.query;
+
+ // Validate limit and offset
+ const parsedLimit = parseInt(limit as string, 10);
+ const parsedOffset = parseInt(offset as string, 10);
+
+ if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 50) {
+ return res.status(400).send({ error: 'INVALID DATA: Limit must be between 1 and 50' });
+ }
+
+ if (isNaN(parsedOffset) || parsedOffset < 0) {
+ return res
+ .status(400)
+ .send({ error: 'INVALID DATA: Offset must be a non-negative number' });
+ }
+
+ const filters: any = {};
+ if (q) {
+ filters.name = q;
+ }
+
+ organizations = await this.organizationService.find(filters, parsedLimit, parsedOffset);
+ total = await this.organizationService.count(filters);
+
+ // Return paginated response
+ const page = Math.floor(parsedOffset / parsedLimit) + 1;
+ const pages = Math.ceil(total / parsedLimit) || 1;
+
+ return res.json({
+ data: organizations,
+ pagination: {
+ offset: parsedOffset,
+ limit: parsedLimit,
+ total,
+ page,
+ pages,
+ },
+ });
+ } else {
+ // Non-admin users see organizations where they are owner or member (no pagination for now)
+ organizations = await this.organizationService.findByUser(req.user.username);
+ res.json({ data: organizations });
+ }
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async getById(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const organization = await this.organizationService.findById(organizationId);
+
+ if (!organization) {
+ return res.status(404).send({ error: `Organization with ID ${organizationId} not found` });
+ }
+
+ res.json(organization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async create(req: any, res: any) {
+ try {
+ const organizationData = req.body;
+ const organization = await this.organizationService.create(organizationData, req.user);
+ res.status(201).json(organization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('does not exist') || err.message.includes('not found')) {
+ return res.status(400).send({ error: err.message });
+ }
+ if (err.message.includes('CONFLICT')) {
+ return res.status(409).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async addMember(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const { username, role } = req.body;
+
+ if (!organizationId) {
+ return res.status(400).send({ error: 'organizationId query parameter is required' });
+ }
+
+ if (!username) {
+ return res.status(400).send({ error: 'username field is required' });
+ }
+
+ await this.organizationService.addMember(organizationId, { username, role }, req.user);
+
+ const updatedOrganization = await this.organizationService.findById(organizationId);
+ res.json(updatedOrganization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('INVALID DATA')) {
+ return res.status(400).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async updateMemberRole(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const username = req.params.username;
+ const { role } = req.body;
+
+ if (!organizationId) {
+ return res.status(400).send({ error: 'organizationId query parameter is required' });
+ }
+
+ if (!username) {
+ return res.status(400).send({ error: 'username field is required' });
+ }
+
+ await this.organizationService.updateMemberRole(organizationId, username, role, req.user);
+
+ const updatedOrganization = await this.organizationService.findById(organizationId);
+ res.json(updatedOrganization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('INVALID DATA')) {
+ return res.status(400).send({ error: err.message });
+ }
+ if (err.message.includes('CONFLICT')) {
+ return res.status(409).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async update(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const updateData = req.body;
+
+ const organization = await this.organizationService.findById(organizationId);
+ if (!organization) {
+ return res.status(404).send({ error: `Organization with ID ${organizationId} not found` });
+ }
+
+ await this.organizationService.update(organizationId, updateData, req.user);
+
+ const updatedOrganization = await this.organizationService.findById(organizationId);
+ res.json(updatedOrganization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('INVALID DATA') || err.message.includes('does not exist')) {
+ return res.status(400).send({ error: err.message });
+ }
+ if (err.message.includes('CONFLICT')) {
+ return res.status(409).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async addApiKey(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const { scope } = req.body;
+
+ if (!organizationId) {
+ return res.status(400).send({ error: 'organizationId query parameter is required' });
+ }
+
+ if (!scope) {
+ return res.status(400).send({ error: 'scope field is required' });
+ }
+
+ await this.organizationService.addApiKey(organizationId, scope, req.user);
+ const updatedOrganization = await this.organizationService.findById(organizationId);
+
+ res.json(updatedOrganization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ } else if (err.message.includes('INVALID DATA')) {
+ return res.status(400).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async removeApiKey(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const apiKey = req.params.apiKey;
+
+ if (!organizationId) {
+ return res.status(400).send({ error: 'organizationId query parameter is required' });
+ }
+
+ if (!apiKey) {
+ return res.status(400).send({ error: 'apiKey field is required' });
+ }
+
+ await this.organizationService.removeApiKey(organizationId, apiKey, req.user);
+ const updatedOrganization = await this.organizationService.findById(organizationId);
+
+ return res.json(updatedOrganization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('not found')) {
+ return res.status(400).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async removeMember(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+ const username = req.params.username;
+ const orgRole = req.user.orgRole;
+
+ if (!organizationId) {
+ return res.status(400).send({ error: 'organizationId parameter is required' });
+ }
+
+ if (!username) {
+ return res.status(400).send({ error: 'username parameter is required' });
+ }
+
+ if (
+ !['OWNER', 'ADMIN', 'MANAGER'].includes(orgRole) &&
+ req.user.role !== 'ADMIN' &&
+ username !== req.user.username
+ ) {
+ return res
+ .status(403)
+ .send({
+ error:
+ 'PERMISSION ERROR: Only users with OWNER, ADMIN, or MANAGER role can remove members, unless the user is removing themselves',
+ });
+ }
+
+ await this.organizationService.removeMember(organizationId, username, req.user);
+ const updatedOrganization = await this.organizationService.findById(organizationId);
+
+ res.json(updatedOrganization);
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('not found')) {
+ return res.status(400).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+
+ async delete(req: any, res: any) {
+ try {
+ const organizationId = req.params.organizationId;
+
+ await this.organizationService.destroy(organizationId, req.user);
+
+ res.status(204).json({ message: 'Organization deleted successfully' });
+ } catch (err: any) {
+ if (err.message.includes('PERMISSION ERROR')) {
+ return res.status(403).send({ error: err.message });
+ }
+ if (err.message.includes('CONFLICT')) {
+ return res.status(409).send({ error: err.message });
+ }
+ res.status(500).send({ error: err.message });
+ }
+ }
+}
+
+export default OrganizationController;
diff --git a/api/src/main/controllers/ServiceController.ts b/api/src/main/controllers/ServiceController.ts
index 5c2c58e..4512d25 100644
--- a/api/src/main/controllers/ServiceController.ts
+++ b/api/src/main/controllers/ServiceController.ts
@@ -26,8 +26,13 @@ class ServiceController {
async index(req: any, res: any) {
try {
const queryParams = this._transformIndexQueryParams(req.query);
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
- const services = await this.serviceService.index(queryParams);
+ if (!organizationId && req.user && req.user.role !== "ADMIN"){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
+ const services = await this.serviceService.index(queryParams, organizationId);
res.json(services);
} catch (err: any) {
@@ -39,6 +44,11 @@ class ServiceController {
try {
let { pricingStatus } = req.query;
const serviceName = req.params.serviceName;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
if (!pricingStatus) {
pricingStatus = 'active';
@@ -47,7 +57,7 @@ class ServiceController {
return;
}
- const pricings = await this.serviceService.indexPricings(serviceName, pricingStatus);
+ const pricings = await this.serviceService.indexPricings(serviceName, pricingStatus, organizationId);
for (const pricing of pricings) {
resetEscapePricingVersion(pricing);
@@ -66,7 +76,13 @@ class ServiceController {
async show(req: any, res: any) {
try {
const serviceName = req.params.serviceName;
- const service = await this.serviceService.show(serviceName);
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
+ const service = await this.serviceService.show(serviceName, organizationId);
return res.json(service);
} catch (err: any) {
@@ -82,8 +98,13 @@ class ServiceController {
try {
const serviceName = req.params.serviceName;
const pricingVersion = req.params.pricingVersion;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
- const pricing = await this.serviceService.showPricing(serviceName, pricingVersion);
+ const pricing = await this.serviceService.showPricing(serviceName, pricingVersion, organizationId);
resetEscapePricingVersion(pricing);
@@ -100,6 +121,12 @@ class ServiceController {
async create(req: any, res: any) {
try {
const receivedFile = req.file;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
let service;
if (!receivedFile) {
@@ -107,9 +134,9 @@ class ServiceController {
res.status(400).send({ error: 'No file or URL provided' });
return;
}
- service = await this.serviceService.create(req.body.pricing, 'url');
+ service = await this.serviceService.create(req.body.pricing, 'url', organizationId);
} else {
- service = await this.serviceService.create(req.file, 'file');
+ service = await this.serviceService.create(req.file, 'file', organizationId);
}
res.status(201).json(service);
} catch (err: any) {
@@ -130,6 +157,12 @@ class ServiceController {
async addPricingToService(req: any, res: any) {
try {
const serviceName = req.params.serviceName;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
const receivedFile = req.file;
let service;
@@ -141,10 +174,11 @@ class ServiceController {
service = await this.serviceService.addPricingToService(
serviceName,
req.body.pricing,
- 'url'
+ 'url',
+ organizationId
);
} else {
- service = await this.serviceService.addPricingToService(serviceName, req.file, 'file');
+ service = await this.serviceService.addPricingToService(serviceName, req.file, 'file', organizationId);
}
res.status(201).json(service);
@@ -165,11 +199,25 @@ class ServiceController {
try {
const newServiceData = req.body;
const serviceName = req.params.serviceName;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
- const service = await this.serviceService.update(serviceName, newServiceData);
+ const service = await this.serviceService.update(serviceName, newServiceData, organizationId);
res.json(service);
} catch (err: any) {
+ if (err.message.toLowerCase().includes('invalid data')) {
+ return res.status(400).send({ error: err.message });
+ }
+ if (err.message.toLowerCase().includes('not found')) {
+ return res.status(404).send({ error: err.message });
+ }
+ if (err.message.toLowerCase().includes('conflict')) {
+ return res.status(409).send({ error: err.message });
+ }
res.status(500).send({ error: err.message });
}
}
@@ -178,6 +226,12 @@ class ServiceController {
try {
const serviceName = req.params.serviceName;
const pricingVersion = req.params.pricingVersion;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
const newAvailability = req.query.availability ?? 'archived';
const fallBackSubscription: FallBackSubscription = req.body ?? {};
@@ -194,7 +248,8 @@ class ServiceController {
serviceName,
pricingVersion,
newAvailability,
- fallBackSubscription
+ fallBackSubscription,
+ organizationId
);
res.json(service);
@@ -214,7 +269,20 @@ class ServiceController {
async prune(req: any, res: any) {
try {
- const result = await this.serviceService.prune();
+ let organizationId = req.org ? req.org.id : req.params.organizationId;
+ if (!organizationId && req.user && req.user.role !== "ADMIN"){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
+ if (req.user && req.user.orgRole !== "OWNER" && req.user.orgRole !== "ADMIN" && req.user.role !== "ADMIN"){
+ return res.status(403).send({ error: 'Forbidden: You do not have permission to prune services' });
+ }
+
+ if (req.user && req.user.role === "ADMIN"){
+ organizationId = undefined;
+ }
+
+ const result = await this.serviceService.prune(organizationId);
res.json({ message: `Pruned ${result} services` });
} catch (err: any) {
res.status(500).send({ error: err.message });
@@ -224,7 +292,13 @@ class ServiceController {
async disable(req: any, res: any) {
try {
const serviceName = req.params.serviceName;
- const result = await this.serviceService.disable(serviceName);
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
+
+ const result = await this.serviceService.disable(serviceName, organizationId);
if (result) {
res.status(204).send();
@@ -244,8 +318,13 @@ class ServiceController {
try {
const serviceName = req.params.serviceName;
const pricingVersion = req.params.pricingVersion;
+ const organizationId = req.org ? req.org.id : req.params.organizationId;
+
+ if (!organizationId){
+ return res.status(400).send({ error: 'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths' });
+ }
- const result = await this.serviceService.destroyPricing(serviceName, pricingVersion);
+ const result = await this.serviceService.destroyPricing(serviceName, pricingVersion, organizationId);
if (result) {
res.status(204).send();
@@ -260,8 +339,8 @@ class ServiceController {
res.status(404).send({ error: err.message });
} else if (err.message.toLowerCase().includes('last active pricing')) {
res.status(400).send({ error: err.message });
- } else if (err.message.toLowerCase().includes('forbidden')) {
- res.status(403).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('conflict')) {
+ res.status(409).send({ error: err.message });
} else {
res.status(500).send({ error: err.message });
}
diff --git a/api/src/main/controllers/UserController.ts b/api/src/main/controllers/UserController.ts
index 7d52194..ff82e5a 100644
--- a/api/src/main/controllers/UserController.ts
+++ b/api/src/main/controllers/UserController.ts
@@ -1,6 +1,6 @@
-import container from '../config/container.js';
-import UserService from '../services/UserService.js';
-import { USER_ROLES } from '../types/models/User.js';
+import container from '../config/container';
+import UserService from '../services/UserService';
+import { USER_ROLES } from '../types/permissions';
class UserController {
private userService: UserService;
@@ -9,6 +9,7 @@ class UserController {
this.userService = container.resolve('userService');
this.create = this.create.bind(this);
this.authenticate = this.authenticate.bind(this);
+ this.getCurrentUser = this.getCurrentUser.bind(this);
this.getAll = this.getAll.bind(this);
this.getByUsername = this.getByUsername.bind(this);
this.update = this.update.bind(this);
@@ -24,7 +25,7 @@ class UserController {
} catch (err: any) {
if (err.name?.includes('ValidationError') || err.code === 11000) {
res.status(422).send({ error: err.message });
- } else if (err.message.toLowerCase().includes('permissions')) {
+ } else if (err.message.toLowerCase().includes('permission error')) {
res.status(403).send({ error: err.message });
} else if (
err.message.toLowerCase().includes('already') ||
@@ -41,16 +42,65 @@ class UserController {
try {
const { username, password } = req.body;
const user = await this.userService.authenticate(username, password);
- res.json({username: user.username, apiKey: user.apiKey, role: user.role });
+ res.json({ username: user.username, apiKey: user.apiKey, role: user.role });
} catch (err: any) {
res.status(401).send({ error: err.message });
}
}
+ async getCurrentUser(req: any, res: any) {
+ try {
+ // req.user is populated by the authentication middleware
+ if (!req.user) {
+ return res.status(401).send({ error: 'Authentication required' });
+ }
+
+ const user = await this.userService.findByUsername(req.user.username);
+ res.json({ username: user.username, role: user.role });
+ } catch (err: any) {
+ res.status(500).send({ error: err.message });
+ }
+ }
+
async getAll(req: any, res: any) {
try {
- const users = await this.userService.getAllUsers();
- res.json(users);
+ let { q, limit, offset } = req.query;
+
+ q = q ?? '';
+
+ if (!req.user){
+ return res.status(401).send({ error: 'Authentication required' });
+ }
+
+ if (q.length === 0 && req.user.role !== 'ADMIN') {
+ return res.status(403).send({ error: 'PERMISSION ERROR: Only admins can retrieve the full user list without a search query' });
+ }
+
+ const searchLimit = limit ? parseInt(limit, 10) : 10;
+ const searchOffset = offset ? parseInt(offset, 10) : 0;
+
+ if (Number.isNaN(searchLimit) || searchLimit < 1 || searchLimit > 50) {
+ return res.status(400).send({ error: 'INVALID DATA: Limit must be between 1 and 50' });
+ }
+
+ if (Number.isNaN(searchOffset) || searchOffset < 0) {
+ return res
+ .status(400)
+ .send({ error: 'INVALID DATA: Offset must be a non-negative number' });
+ }
+
+ const users = await this.userService.getUsers(q, searchLimit, searchOffset);
+ const total = await this.userService.countUsers(q);
+ return res.json({
+ data: users,
+ pagination: {
+ offset: searchOffset,
+ limit: searchLimit,
+ total,
+ page: Math.floor(searchOffset / searchLimit) + 1,
+ pages: Math.ceil(total / searchLimit),
+ },
+ });
} catch (err: any) {
res.status(500).send({ error: err.message });
}
@@ -72,9 +122,9 @@ class UserController {
} catch (err: any) {
if (err.name?.includes('ValidationError') || err.code === 11000) {
res.status(422).send({ error: err.message });
- } else if (err.message.toLowerCase().includes('permissions')) {
+ } else if (err.message.toLowerCase().includes('permission error')) {
res.status(403).send({ error: err.message });
- }else if (
+ } else if (
err.message.toLowerCase().includes('already') ||
err.message.toLowerCase().includes('not found')
) {
@@ -87,7 +137,7 @@ class UserController {
async regenerateApiKey(req: any, res: any) {
try {
- const newApiKey = await this.userService.regenerateApiKey(req.params.username);
+ const newApiKey = await this.userService.regenerateApiKey(req.params.username, req.user);
res.json({ apiKey: newApiKey });
} catch (err: any) {
if (
@@ -95,9 +145,9 @@ class UserController {
err.message.toLowerCase().includes('not found')
) {
res.status(404).send({ error: err.message });
- } else if (err.message.toLowerCase().includes('permissions')) {
+ } else if (err.message.toLowerCase().includes('permission error')) {
res.status(403).send({ error: err.message });
- }else {
+ } else {
res.status(500).send({ error: err.message });
}
}
@@ -113,9 +163,9 @@ class UserController {
const user = await this.userService.changeRole(req.params.username, role, req.user);
res.json(user);
} catch (err: any) {
- if (err.message.toLowerCase().includes('permissions')) {
+ if (err.message.toLowerCase().includes('permission error')) {
res.status(403).send({ error: err.message });
- }else if (
+ } else if (
err.message.toLowerCase().includes('already') ||
err.message.toLowerCase().includes('not found')
) {
@@ -128,7 +178,7 @@ class UserController {
async destroy(req: any, res: any) {
try {
- await this.userService.destroy(req.params.username);
+ await this.userService.destroy(req.params.username, req.user);
res.status(204).send();
} catch (err: any) {
if (
@@ -136,6 +186,8 @@ class UserController {
err.message.toLowerCase().includes('not found')
) {
res.status(404).send({ error: err.message });
+ } else if (err.message.toLowerCase().includes('permission error')) {
+ res.status(403).send({ error: err.message });
} else {
res.status(500).send({ error: err.message });
}
diff --git a/api/src/main/controllers/validation/ContractValidation.ts b/api/src/main/controllers/validation/ContractValidation.ts
index cb74d7f..89199bf 100644
--- a/api/src/main/controllers/validation/ContractValidation.ts
+++ b/api/src/main/controllers/validation/ContractValidation.ts
@@ -3,6 +3,7 @@ import { LeanPricing } from '../../types/models/Pricing';
import { Subscription } from '../../types/models/Contract';
import ServiceService from '../../services/ServiceService';
import container from '../../config/container';
+import { convertKeysToLowercase } from '../../utils/helpers';
const create = [
// userContact (required)
@@ -238,7 +239,7 @@ const novateBillingPeriod = [
.withMessage('renewalDays must be a positive integer'),
];
-async function isSubscriptionValid(subscription: Subscription): Promise {
+async function isSubscriptionValid(subscription: Subscription, organizationId: string): Promise {
const selectedPricings: Record = {};
const serviceService: ServiceService = container.resolve('serviceService');
@@ -246,11 +247,17 @@ async function isSubscriptionValid(subscription: Subscription): Promise {
const pricingPromises = Object.entries(subscription.contractedServices).map(
async ([serviceName, pricingVersion]) => {
try {
- const pricing = await serviceService.showPricing(serviceName, pricingVersion);
+ const pricing = await serviceService.showPricing(serviceName, pricingVersion, organizationId);
+ if (!pricing) {
+ throw new Error(
+ `Pricing version ${pricingVersion} for service ${serviceName} not found in the request organization`
+ );
+ }
+
return { serviceName, pricing };
} catch (error) {
throw new Error(
- `Pricing version ${pricingVersion} for service ${serviceName} not found`
+ `Pricing version ${pricingVersion} for service ${serviceName} not found in the request organization`
);
}
}
@@ -276,7 +283,7 @@ async function isSubscriptionValid(subscription: Subscription): Promise {
if (!pricing) {
throw new Error(
- `Service ${serviceName} not found. Please check the services declared in subscriptionPlans and subscriptionAddOns.`
+ `NOT FOUND: Service with name ${serviceName} in the request organization. Please check the services declared in subscriptionPlans and subscriptionAddOns.`
);
}
@@ -291,6 +298,7 @@ function isSubscriptionValidInPricing(
): void {
const selectedPlan: string | undefined = subscription.subscriptionPlans[serviceName];
const selectedAddOns = subscription.subscriptionAddOns[serviceName];
+ const pricingPlans = convertKeysToLowercase(pricing.plans || {});
if (!selectedPlan && !selectedAddOns) {
throw new Error(
@@ -298,9 +306,9 @@ function isSubscriptionValidInPricing(
);
}
- if (selectedPlan && !(pricing.plans || {})[selectedPlan]) {
+ if (selectedPlan && !pricingPlans[selectedPlan.toLowerCase()]) {
throw new Error(
- `Plan ${selectedPlan} for service ${serviceName} not found`
+ `Plan ${selectedPlan} for service ${serviceName} not found in the request organization`
);
}
@@ -316,10 +324,12 @@ function _validateAddOns(
return;
}
+ const pricingAddOns = convertKeysToLowercase(pricing.addOns || {});
+
for (const addOnName in selectedAddOns) {
- if (!pricing.addOns![addOnName]){
- throw new Error(`Add-on ${addOnName} declared in the subscription not found in pricing version ${pricing.version}`);
+ if (!pricingAddOns[addOnName.toLowerCase()]){
+ throw new Error(`Add-on ${addOnName} declared in the subscription not found in pricing version ${pricing.version} in the request organization`);
}
_validateAddOnAvailability(addOnName, selectedPlan, pricing);
@@ -336,10 +346,10 @@ function _validateAddOnAvailability(
): void {
if (
selectedPlan && pricing.addOns![addOnName] &&
- !(pricing.addOns![addOnName].availableFor ?? Object.keys(pricing.plans!))?.includes(selectedPlan)
+ !(pricing.addOns![addOnName].availableFor ?? Object.keys(pricing.plans!))?.map(p => p.toLowerCase()).includes(selectedPlan.toLowerCase())
) {
throw new Error(
- `Add-on ${addOnName} is not available for plan ${selectedPlan}`
+ `Add-on ${addOnName} is not available for plan ${selectedPlan} in the request organization`
);
}
}
@@ -353,7 +363,7 @@ function _validateDependentAddOns(
const dependentAddOns = pricing.addOns![addOnName].dependsOn ?? [];
if (!dependentAddOns.every(dependentAddOn => selectedAddOns.hasOwnProperty(dependentAddOn))) {
throw new Error(
- `Add-on ${addOnName} requires the following add-ons to be selected: ${dependentAddOns.join(', ')}`
+ `Add-on ${addOnName} requires the following add-ons to be selected in the request organization: ${dependentAddOns.join(', ')}`
);
}
}
@@ -366,7 +376,7 @@ function _validateExcludedAddOns(
const excludedAddOns = pricing.addOns![addOnName].excludes ?? [];
if (excludedAddOns.some(excludedAddOn => selectedAddOns.hasOwnProperty(excludedAddOn))) {
throw new Error(
- `Add-on ${addOnName} cannot be selected with the following add-ons: ${excludedAddOns.join(', ')}`
+ `Add-on ${addOnName} cannot be selected with the following add-ons in the request organization: ${excludedAddOns.join(', ')}`
);
}
}
@@ -378,7 +388,7 @@ function _validateAddOnQuantity(
): void {
const quantity = selectedAddOns[addOnName];
const minQuantity = pricing.addOns![addOnName].subscriptionConstraints?.minQuantity ?? 1;
- const maxQuantity = pricing.addOns![addOnName].subscriptionConstraints?.maxQuantity ?? 1;
+ const maxQuantity = pricing.addOns![addOnName].subscriptionConstraints?.maxQuantity ?? Infinity;
const quantityStep = pricing.addOns![addOnName].subscriptionConstraints?.quantityStep ?? 1;
const isValidQuantity =
@@ -388,7 +398,7 @@ function _validateAddOnQuantity(
if (!isValidQuantity) {
throw new Error(
- `Add-on ${addOnName} quantity ${quantity} is not valid. It must be between ${minQuantity} and ${maxQuantity}, and a multiple of ${quantityStep}`
+ `Add-on ${addOnName} quantity ${quantity} is not valid. It must be between ${minQuantity} and ${maxQuantity}, and a multiple of ${quantityStep} in the request organization`
);
}
}
diff --git a/api/src/main/controllers/validation/OrganizationValidation.ts b/api/src/main/controllers/validation/OrganizationValidation.ts
new file mode 100644
index 0000000..2bfd75e
--- /dev/null
+++ b/api/src/main/controllers/validation/OrganizationValidation.ts
@@ -0,0 +1,52 @@
+import { body, check } from 'express-validator';
+
+const getById = [
+ check('organizationId')
+ .exists().withMessage('organizationId parameter is required')
+ .isString().withMessage('organizationId must be a string')
+ .isLength({ min: 24, max: 24 }).withMessage('organizationId must be a valid 24-character hex string'),
+]
+
+const create = [
+ check('name')
+ .exists().withMessage('Organization name is required')
+ .isString().withMessage('Organization name must be a string')
+ .notEmpty().withMessage('Organization name cannot be empty')
+ .isLength({ min: 3 }).withMessage('Organization name must be at least 3 characters long'),
+ check('owner')
+ .exists().withMessage('Owner username is required')
+ .isString().withMessage('Owner username must be a string')
+]
+
+const update = [
+ body('name')
+ .optional()
+ .isString().withMessage('Organization name must be a string'),
+ body('owner')
+ .optional()
+ .isString().withMessage('Owner username must be a string')
+];
+
+const updateMemberRole = [
+ body('role')
+ .exists().withMessage('Member role is required')
+ .notEmpty().withMessage('Member role cannot be empty')
+ .isIn(['ADMIN', 'MANAGER', 'EVALUATOR']).withMessage('Member role must be one of ADMIN, MANAGER, EVALUATOR')
+]
+
+const addMember = [
+ check('organizationId')
+ .exists().withMessage('organizationId parameter is required')
+ .isString().withMessage('organizationId must be a string')
+ .isLength({ min: 24, max: 24 }).withMessage('organizationId must be a valid 24-character hex string'),
+ body('username')
+ .exists().withMessage('Member username is required')
+ .notEmpty().withMessage('Member username cannot be empty')
+ .isString().withMessage('Member username must be a string'),
+ body('role')
+ .exists().withMessage('Member role is required')
+ .notEmpty().withMessage('Member role cannot be empty')
+ .isIn(['ADMIN', 'MANAGER', 'EVALUATOR']).withMessage('Member role must be one of ADMIN, MANAGER, EVALUATOR')
+]
+
+export { create, update, updateMemberRole, getById, addMember };
\ No newline at end of file
diff --git a/api/src/main/controllers/validation/ServiceValidation.ts b/api/src/main/controllers/validation/ServiceValidation.ts
index 3da1b59..b1c43d6 100644
--- a/api/src/main/controllers/validation/ServiceValidation.ts
+++ b/api/src/main/controllers/validation/ServiceValidation.ts
@@ -6,7 +6,14 @@ const update = [
.isString()
.withMessage('The name field must be a string')
.isLength({ min: 1, max: 255 })
- .withMessage('The name must have between 1 and 255 characters long')
+ .withMessage('The name must be between 1 and 255 characters long')
+ .trim(),
+ check('organizationId')
+ .optional()
+ .isString()
+ .withMessage('The organizationId field must be a string')
+ .isLength({ min: 24, max: 24 })
+ .withMessage('The organizationId must be 24 characters long')
.trim()
];
diff --git a/api/src/main/controllers/validation/UserValidation.ts b/api/src/main/controllers/validation/UserValidation.ts
index 819051c..e812e81 100644
--- a/api/src/main/controllers/validation/UserValidation.ts
+++ b/api/src/main/controllers/validation/UserValidation.ts
@@ -1,5 +1,5 @@
import { check } from 'express-validator';
-import { USER_ROLES } from '../../types/models/User';
+import { USER_ROLES } from '../../types/permissions';
const create = [
check('username')
diff --git a/api/src/main/database/seeders/common/userSeeder.ts b/api/src/main/database/seeders/common/userSeeder.ts
index 711e302..3c49132 100644
--- a/api/src/main/database/seeders/common/userSeeder.ts
+++ b/api/src/main/database/seeders/common/userSeeder.ts
@@ -1,4 +1,6 @@
import UserMongoose from '../../../repositories/mongoose/models/UserMongoose';
+import OrganizationMongoose from '../../../repositories/mongoose/models/OrganizationMongoose';
+import { generateOrganizationApiKey, generateUserApiKey } from '../../../utils/users/helpers';
/**
* Creates a default admin user if it does not exist
@@ -19,17 +21,38 @@ export const seedDefaultAdmin = async () => {
const admin = new UserMongoose({
username: adminUsername,
password: adminPassword, // It will be automatically encrypted by the pre-save hook
+ apiKey: generateUserApiKey(),
role: 'ADMIN'
});
// Save admin
await admin.save();
+ // Create default organization for admin user
+ const adminOrganization = new OrganizationMongoose({
+ name: process.env.ADMIN_ORG_NAME ?? 'admin\'s Organization',
+ owner: adminUsername,
+ default: true,
+ apiKeys: [
+ {
+ key: generateOrganizationApiKey(),
+ scope: 'ALL'
+ }
+ ],
+ members: []
+ });
+
+ // Save organization
+ await adminOrganization.save();
+
if (process.env.ENVIRONMENT !== 'production') {
console.log('Admin user successfully created:');
console.log(`\tUsername: ${adminUsername}`);
console.log(`\tPassword: ${adminPassword}`);
console.log(`\tAPI Key: ${admin.apiKey}`);
+ console.log('\nAdmin organization successfully created:');
+ console.log(`\tName: ${adminOrganization.name}`);
+ console.log(`\tOrganization API Key: ${adminOrganization.apiKeys[0].key}`);
}
return admin;
diff --git a/api/src/main/database/seeders/mongo/contracts/contracts.json b/api/src/main/database/seeders/mongo/contracts/contracts.json
index 762fa6d..d342517 100644
--- a/api/src/main/database/seeders/mongo/contracts/contracts.json
+++ b/api/src/main/database/seeders/mongo/contracts/contracts.json
@@ -10,7 +10,7 @@
},
"billingPeriod": {
"startDate": {"$date": "2025-05-01T00:00:00.000Z"},
- "endDate": {"$date": "2025-06-01T00:00:00.000Z"},
+ "endDate": {"$date": "2026-06-01T00:00:00.000Z"},
"autoRenew": true,
"renewalDays": 30
},
@@ -25,6 +25,7 @@
}
}
},
+ "organizationId": "63f74bf8eeed64054274b60d",
"contractedServices": {
"zoom": "2_0_0"
},
@@ -83,6 +84,7 @@
}
}
},
+ "organizationId": "63f74bf8eeed64054274b60d",
"contractedServices": {
"zoom": "2_0_0"
},
@@ -136,6 +138,7 @@
}
}
},
+ "organizationId": "63f74bf8eeed64054274b60d",
"contractedServices": {
"zoom": "2_0_0"
},
diff --git a/api/src/main/database/seeders/mongo/organizations/organizations.json b/api/src/main/database/seeders/mongo/organizations/organizations.json
new file mode 100644
index 0000000..6f2519d
--- /dev/null
+++ b/api/src/main/database/seeders/mongo/organizations/organizations.json
@@ -0,0 +1,67 @@
+[
+ {
+ "_id": {
+ "$oid": "63f74bf8eeed64054274b60a"
+ },
+ "name": "Admin Organization",
+ "owner": "testAdmin",
+ "default": false,
+ "apiKeys": [
+ {
+ "key": "org_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
+ "scope": "ALL"
+ }
+ ],
+ "members": []
+ },
+ {
+ "_id": {
+ "$oid": "63f74bf8eeed64054274b60b"
+ },
+ "name": "User One Organization",
+ "owner": "testUser",
+ "default": false,
+ "apiKeys": [
+ {
+ "key": "org_b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u",
+ "scope": "ALL"
+ }
+ ],
+ "members": []
+ },
+ {
+ "_id": {
+ "$oid": "63f74bf8eeed64054274b60c"
+ },
+ "name": "User Two Organization",
+ "owner": "testUser2",
+ "default": false,
+ "apiKeys": [
+ {
+ "key": "org_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1",
+ "scope": "ALL"
+ }
+ ],
+ "members": []
+ },
+ {
+ "_id": {
+ "$oid": "63f74bf8eeed64054274b60d"
+ },
+ "name": "Shared Organization",
+ "owner": "testUser",
+ "default": false,
+ "apiKeys": [
+ {
+ "key": "org_d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2",
+ "scope": "ALL"
+ }
+ ],
+ "members": [
+ {
+ "username": "testUser2",
+ "role": "EVALUATOR"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/api/src/main/database/seeders/mongo/pricings/pricings.json b/api/src/main/database/seeders/mongo/pricings/pricings.json
index 85dd9bd..adfe310 100644
--- a/api/src/main/database/seeders/mongo/pricings/pricings.json
+++ b/api/src/main/database/seeders/mongo/pricings/pricings.json
@@ -4,6 +4,7 @@
"$oid": "68162ba1a887819d643e6e1f"
},
"_serviceName": "Zoom",
+ "_organizationId": "63f74bf8eeed64054274b60d",
"version": "2_0_0",
"currency": "USD",
"createdAt": "2024-07-17T00:00:00.000Z",
diff --git a/api/src/main/database/seeders/mongo/services/services.json b/api/src/main/database/seeders/mongo/services/services.json
index 2f17211..4d78782 100644
--- a/api/src/main/database/seeders/mongo/services/services.json
+++ b/api/src/main/database/seeders/mongo/services/services.json
@@ -5,6 +5,7 @@
},
"name": "Zoom",
"disabled": false,
+ "organizationId": "63f74bf8eeed64054274b60d",
"activePricings": {
"2_0_0": {
"id": {
diff --git a/api/src/main/database/seeders/mongo/users/users.json b/api/src/main/database/seeders/mongo/users/users.json
index 2a158c6..7793b72 100644
--- a/api/src/main/database/seeders/mongo/users/users.json
+++ b/api/src/main/database/seeders/mongo/users/users.json
@@ -6,24 +6,24 @@
"username": "testAdmin",
"password": "$2b$10$zk1.UGu1tPipj1yJAL0QseWoQiShyBgNnDj6bJG8Y0iPV08uYmg0e",
"role": "ADMIN",
- "apiKey": "9cedd24632167a021667df44a26362dfb778c1566c3d4564e132cb58770d8c67"
+ "apiKey": "usr_9cedd24632167a021667df44a26362dfb778c1566c3d4564e132cb58770d8c67"
},
{
"_id": {
"$oid": "63f74bf8eeed64054274b529"
},
- "username": "testManager",
- "password": "$2b$10$WAMkVAo9.QDHmQWdiiIE8uUrKXTWJbDf6BrifHbOplF.lpubtip.W",
- "role": "MANAGER",
- "apiKey": "c2b9efdd8d1a42e0b5ef6c3a179438a8d42c6f44fe76e3a0ccabe6cd44cf6d12"
+ "username": "testUser",
+ "password": "$2b$10$K1v8xQ0q2n8s6aZ3h4J7uOWMZbQ2r8s9tU1v2wX3yZ4a5b6c7d8ef",
+ "role": "USER",
+ "apiKey": "usr_c2b9efdd8d1a42e0b5ef6c3a179438a8d42c6f44fe76e3a0ccabe6cd44cf6d12"
},
{
"_id": {
- "$oid": "682e3157a837d201635360c6"
+ "$oid": "63f74bf8eeed64054274b532"
},
- "username": "testEvaluator",
- "password": "$2b$10$CyoH9BYobjK3TXZqpWhUL.nEbR65xT3hhFFSkH.s6nAO06c.aCSaC",
- "role": "EVALUATOR",
- "apiKey": "1f4a8d537f209b1e8cf0d2a76582f9a7b77eb336ce6f4b5a9e9fae20497cfc4b"
+ "username": "testUser2",
+ "password": "$2b$10$K1v8xQ0q2n8s6aZ3h4J7uOWMZbQ2r8s9tU1v2wX3yZ4a5b6c7d8ef",
+ "role": "USER",
+ "apiKey": "usr_c2b9efdd8d1a42e0b5ef6c3a179438a8d42c6f44fe76e3a0ccabe6cd44cf6d09"
}
]
\ No newline at end of file
diff --git a/api/src/main/middlewares/ApiKeyAuthMiddleware.ts b/api/src/main/middlewares/ApiKeyAuthMiddleware.ts
index 7fc6660..3029a06 100644
--- a/api/src/main/middlewares/ApiKeyAuthMiddleware.ts
+++ b/api/src/main/middlewares/ApiKeyAuthMiddleware.ts
@@ -1,30 +1,78 @@
-import { Request, Response, NextFunction, Router } from 'express';
-import { authenticateApiKey, hasPermission } from './AuthMiddleware';
-
-// Public routes that won't require authentication
-const PUBLIC_ROUTES = [
- '/users/authenticate',
- '/healthcheck'
-];
-
-/**
- * Middleware that applies authentication and permission verification to all routes
- * except those specified as public
- */
-export const apiKeyAuthMiddleware = (req: Request, res: Response, next: NextFunction) => {
- const baseUrl = process.env.BASE_URL_PATH || '/api/v1';
-
- // Check if the current route is public (doesn't require authentication)
- const path = req.path.replace(baseUrl, '');
- const isPublicRoute = PUBLIC_ROUTES.some(route => path.startsWith(route));
+import { Response, NextFunction } from 'express';
+import container from '../config/container';
+import { OrganizationMember } from '../types/models/Organization';
+
+export function hasUserRole(roles: string[]) {
+ return (req: any, res: Response, next: NextFunction) => {
+ if (req.user && roles.includes(req.user.role)) {
+ return next();
+ } else {
+ return res.status(403).send({ error: `Insufficient permissions. Required: ${roles.join(', ')}` });
+ }
+ }
+}
+
+export async function isOrgOwner(req: any, res: Response, next: NextFunction) {
- if (isPublicRoute) {
+ const organizationService = container.resolve('organizationService');
+
+ const organizationId = req.params.organizationId;
+ const organization = await organizationService.findById(organizationId);
+
+ if (!organization) {
+ return res.status(404).send({ error: `Organization with ID ${organizationId} not found` });
+ }
+
+ if (organization.owner === req.user.username || req.user.role === 'ADMIN') {
return next();
+ } else {
+ return res.status(403).send({ error: `You are not the owner of organization ${organizationId}` });
}
+}
+
+export async function isOrgMember(req: any, res: Response, next: NextFunction) {
- // Apply authentication and permission verification
- authenticateApiKey(req, res, (err?: any) => {
- if (err) return next(err);
- hasPermission(req, res, next);
- });
-};
+ const organizationService = container.resolve('organizationService');
+
+ const organizationId = req.params.organizationId;
+ const organization = await organizationService.findById(organizationId);
+
+ if (organization.owner === req.user.username ||
+ organization.members.map((member: OrganizationMember) => member.username).includes(req.user.username) ||
+ req.user.role === 'ADMIN') {
+ return next();
+ } else {
+ return res.status(403).send({ error: `You are not a member of organization ${organizationId}` });
+ }
+}
+
+export function hasOrgRole(roles: string[]) {
+ return async (req: any, res: Response, next: NextFunction) => {
+
+ const organizationService = container.resolve('organizationService');
+
+ const organizationId = req.params.organizationId;
+ const organization = await organizationService.findById(organizationId);
+
+ if (!organization) {
+ return res.status(404).send({ error: `Organization with ID ${organizationId} not found` });
+ }
+
+ let userRoleInOrg = null;
+
+ if (organization.owner === req.user.username) {
+ userRoleInOrg = 'OWNER';
+ } else {
+ const member = organization.members.find((member: OrganizationMember) => member.username === req.user.username);
+ if (member) {
+ userRoleInOrg = member.role;
+ }
+ }
+
+ if ((userRoleInOrg && roles.includes(userRoleInOrg)) || req.user.role === 'ADMIN') {
+ return next();
+ } else {
+ return res.status(403).send({ error: `PERMISSIONS ERROR: Insufficient organization permissions. Required: ${roles.join(', ')}` });
+ }
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/middlewares/AuthMiddleware.ts b/api/src/main/middlewares/AuthMiddleware.ts
index 3509efb..af2fc5b 100644
--- a/api/src/main/middlewares/AuthMiddleware.ts
+++ b/api/src/main/middlewares/AuthMiddleware.ts
@@ -1,73 +1,247 @@
-import { NextFunction } from 'express';
+import { Request, Response, NextFunction } from 'express';
import container from '../config/container';
-import { RestOperation, Role, ROLE_PERMISSIONS, USER_ROLES } from '../types/models/User';
+import {
+ ROUTE_PERMISSIONS,
+ DEFAULT_PERMISSION_DENIED_MESSAGE,
+ ORG_KEY_USER_ROUTE_MESSAGE,
+} from '../config/permissions';
+import { matchPath, extractApiPath } from '../utils/routeMatcher';
+import { LeanOrganization, OrganizationMember, OrganizationUserRole } from '../types/models/Organization';
+import { HttpMethod, OrganizationApiKeyRole } from '../types/permissions';
-// Middleware to verify API Key
-const authenticateApiKey = async (req: any, res: any, next: NextFunction) => {
- const userService = container.resolve('userService');
-
- // Get the API Key from the header
- const apiKey = req.headers['x-api-key'];
- if (!apiKey) {
- return res.status(401).send({ error: 'API Key not found. Please ensure to add an API Key as value of the "x-api-key" header.' });
- }
+/**
+ * Middleware to authenticate API Keys (both User and Organization types)
+ *
+ * Supports two types of API Keys:
+ * 1. User API Keys (prefix: "usr_") - Authenticates a specific user
+ * 2. Organization API Keys (prefix: "org_") - Authenticates at organization level
+ *
+ * Sets req.user for User API Keys
+ * Sets req.org for Organization API Keys
+ */
+const authenticateApiKeyMiddleware = async (req: Request, res: Response, next: NextFunction) => {
+ const apiKey = req.headers['x-api-key'] as string;
try {
- const user = await userService.findByApiKey(apiKey);
- req.user = user;
- next();
- } catch (err) {
- return res.status(401).send({ error: 'Invalid API Key' });
+ // Determine API Key type based on prefix
+ if (!apiKey) {
+ return checkPermissions(req, res, next);
+ } else if (apiKey.startsWith('usr_')) {
+ // User API Key authentication
+ await authenticateUserApiKey(req, apiKey);
+ } else if (apiKey.startsWith('org_')) {
+ // Organization API Key authentication
+ await authenticateOrgApiKey(req, apiKey);
+ } else {
+ return res.status(401).json({
+ error: 'Invalid API Key format. API Keys must start with "usr_" or "org_"',
+ });
+ }
+
+ return checkPermissions(req, res, next);
+ } catch (err: any) {
+ if (!res.headersSent) {
+ return res.status(401).json({
+ error: err.message || 'Invalid API Key',
+ });
+ }
}
};
-// Middleware to verify role and permissions
-const hasPermission = (req: any, res: any, next: NextFunction) => {
+/**
+ * Authenticates a User API Key and populates req.user
+ */
+async function authenticateUserApiKey(req: Request, apiKey: string): Promise {
+ const userService = container.resolve('userService');
+
+ const user = await userService.findByApiKey(apiKey);
+
+ if (!user) {
+ throw new Error('Invalid User API Key');
+ }
+
+ req.user = user;
+ req.authType = 'user';
+}
+
+/**
+ * Authenticates an Organization API Key and populates req.org
+ */
+async function authenticateOrgApiKey(req: Request, apiKey: string): Promise {
+ const organizationRepository = container.resolve('organizationRepository');
+
+ // Find organization by API Key
+ const result: LeanOrganization = await organizationRepository.findByApiKey(apiKey);
+
+ if (!result) {
+ throw new Error('Invalid Organization API Key');
+ }
+
+ req.org = {
+ id: result.id!,
+ name: result.name,
+ members: result.members,
+ role: result.apiKeys.find(key => key.key === apiKey)!.scope as OrganizationApiKeyRole,
+ };
+ req.authType = 'organization';
+}
+
+/**
+ * Middleware to verify permissions based on route configuration
+ *
+ * Checks if the authenticated entity (user or organization) has permission
+ * to access the requested route with the specified HTTP method.
+ *
+ * Must be used AFTER authenticateApiKey middleware.
+ */
+const checkPermissions = (req: Request, res: Response, next: NextFunction) => {
try {
- if (!req.user) {
- return res.status(403).send({ error: 'User not authenticated' });
- }
+ const method = req.method.toUpperCase() as HttpMethod;
+ const baseUrlPath = process.env.BASE_URL_PATH || '/api/v1';
+ const apiPath = extractApiPath(req.path, baseUrlPath);
- const roleId: Role = req.user.role ?? USER_ROLES[USER_ROLES.length - 1];
- const role = ROLE_PERMISSIONS[roleId];
+ // Find matching permission rule
+ const matchingRule = ROUTE_PERMISSIONS.find(rule => {
+ const methodMatches = rule.methods.includes(method);
+ const pathMatches = matchPath(rule.path, apiPath);
+ return methodMatches && pathMatches;
+ });
- if (!role) {
- return res.status(403).send({ error: `Your role does not have permissions. Current role: ${roleId}`});
+ // If no rule matches, deny by default
+ if (!matchingRule) {
+ return res.status(403).json({
+ error: DEFAULT_PERMISSION_DENIED_MESSAGE,
+ details: `No permission rule found for ${method} ${apiPath}`,
+ });
}
- const method: string = req.method;
- const module = req.path.split(`${process.env.BASE_URL_PATH ?? "/api/v1"}`)[1].split('/')[1];
-
- if (role.allowAll){
+ // Allow public routes without authentication
+ if (matchingRule.isPublic) {
return next();
}
- if (role.blockedMethods && Object.keys(role.blockedMethods).includes(method.toUpperCase())) {
- const blockedModules = role.blockedMethods[method.toUpperCase() as RestOperation];
-
- if (blockedModules?.includes("*") || blockedModules?.some(service => module.startsWith(service))) {
- return res.status(403).send({ error: `Operation not permitted with your role. Current role: ${roleId}` });
- }
+ // Protected route - require authentication
+ if (!req.authType) {
+ return res.status(401).json({
+ error:
+ 'API Key not found. Please ensure to add an API Key as value of the "x-api-key" header.',
+ });
}
-
- // If the method is not blocked, and no configuration of allowance is set, allow the request
- if (!role.allowedMethods){
- return next();
+
+ // Check if this route requires a user API key
+ if (matchingRule.requiresUser && req.authType === 'organization') {
+ return res.status(403).json({
+ error: ORG_KEY_USER_ROUTE_MESSAGE,
+ });
}
- if (role.allowedMethods && Object.keys(role.allowedMethods).includes(method.toUpperCase())) {
- const allowedModules = role.allowedMethods[method.toUpperCase() as RestOperation];
+ // Verify permissions based on auth type
+ if (req.authType === 'user' && req.user) {
+ // User API Key - check user role
+ if (
+ !matchingRule.allowedUserRoles ||
+ !matchingRule.allowedUserRoles.includes(req.user.role)
+ ) {
+ return res.status(403).json({
+ error: `Your user role (${req.user.role}) does not have permission to ${method} ${apiPath}`,
+ });
+ }
+ } else if (req.authType === 'organization' && req.org) {
+ // Organization API Key - check org key role
+ if (!matchingRule.allowedOrgRoles || !matchingRule.allowedOrgRoles.includes(req.org.role)) {
+ return res.status(403).json({
+ error: `Your organization API key role (${req.org.role}) does not have permission to ${method} ${apiPath}`,
+ });
+ }
+ } else {
+ // No valid authentication found
+ return res.status(401).json({
+ error: 'Authentication required',
+ });
+ }
- if (allowedModules?.includes("*") || allowedModules?.some(service => module.startsWith(service))) {
+ // Permission granted
+ next();
+ } catch (error) {
+ return res.status(500).json({
+ error: 'Internal error while verifying permissions',
+ });
+ }
+};
+
+const memberRole = async (req: Request, res: Response, next: NextFunction) => {
+ if (!req.user && !req.org) {
+ return res.status(401).json({
+ error: 'Authentication required',
+ });
+ }
+
+ if (req.authType === 'user') {
+ try {
+ const organizationService = container.resolve('organizationService');
+ const organizationId = req.params.organizationId;
+ const organization = await organizationService.findById(organizationId);
+
+ if (!organization) {
+ return res.status(404).json({
+ error: 'Organization with ID ' + organizationId + ' not found',
+ });
+ }
+
+ if (organization.owner === req.user!.username) {
+ req.user!.orgRole = 'OWNER';
return next();
}
- }
+
+ const member = organization.members.find(
+ (member: OrganizationMember) => member.username === req.user!.username
+ );
- return res.status(403).send({ error: `Operation not permitted with your role. Current role: ${roleId}` });
+ if (member) {
+ req.user!.orgRole = member.role as OrganizationUserRole;
+ }
- } catch (error) {
- return res.status(500).send({ error: 'Error verifying permissions' });
+ if (!req.user!.orgRole && req.user!.role !== 'ADMIN') {
+ return res.status(403).json({
+ error:
+ 'This route requires user authentication. Either you did not provide an user API key or your are not a member of this organization',
+ });
+ }
+
+ next();
+ } catch (error: any) {
+ // Handle invalid organization ID or other errors
+ return res.status(404).json({
+ error: error.message || 'Organization not found',
+ });
+ }
+ } else {
+ next();
}
};
-export { authenticateApiKey, hasPermission };
+const hasPermission = (requiredRoles: (OrganizationApiKeyRole | OrganizationUserRole)[]) => {
+ return async (req: Request, res: Response, next: NextFunction) => {
+ if (req.user && req.user.role === 'ADMIN') {
+ return next();
+ }
+
+ if (!req.user?.orgRole) {
+ return res.status(401).json({
+ error: 'This route requires user authentication. Either you did not provide an user API key or your are not a member of this organization',
+ });
+ }
+
+ if (!requiredRoles.includes(req.user!.orgRole as OrganizationUserRole)) {
+ return res.status(403).json({
+ error:
+ 'You do not have permission to access this resource. Allowed roles: ' +
+ requiredRoles.join(', '),
+ });
+ }
+
+ next();
+ };
+};
+
+export { authenticateApiKeyMiddleware, memberRole, hasPermission };
diff --git a/api/src/main/middlewares/GlobalMiddlewaresLoader.ts b/api/src/main/middlewares/GlobalMiddlewaresLoader.ts
index 79fa7e1..91c7c8c 100644
--- a/api/src/main/middlewares/GlobalMiddlewaresLoader.ts
+++ b/api/src/main/middlewares/GlobalMiddlewaresLoader.ts
@@ -1,8 +1,8 @@
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
-import { apiKeyAuthMiddleware } from './ApiKeyAuthMiddleware';
import { analyticsTrackerMiddleware } from './AnalyticsMiddleware';
+import { authenticateApiKeyMiddleware } from './AuthMiddleware';
interface OriginValidatorCallback {
(err: Error | null, allow?: boolean): void;
@@ -27,6 +27,15 @@ const corsOptions: cors.CorsOptions = {
const loadGlobalMiddlewares = (app: express.Application) => {
app.use(express.json({limit: '2mb'}));
app.use(express.urlencoded({limit: '2mb', extended: true}));
+
+ // This replacer will convert Maps to plain objects in JSON responses
+ app.set('json replacer', (key: any, value: any) => {
+ if (value instanceof Map) {
+ return Object.fromEntries(value);
+ }
+ return value;
+ });
+
app.use(cors(corsOptions));
app.options("*", cors(corsOptions)); // maneja todas las preflight
@@ -43,9 +52,8 @@ const loadGlobalMiddlewares = (app: express.Application) => {
));
app.use(express.static('public'));
- // Apply API Key authentication middleware to all routes
- // except those defined as public
- app.use(apiKeyAuthMiddleware);
+ // Populate request with user info based on API key
+ app.use(authenticateApiKeyMiddleware);
// Apply analytics tracking middleware
app.use(analyticsTrackerMiddleware);
diff --git a/api/src/main/repositories/mongoose/ContractRepository.ts b/api/src/main/repositories/mongoose/ContractRepository.ts
index eda0aea..ac5ad13 100644
--- a/api/src/main/repositories/mongoose/ContractRepository.ts
+++ b/api/src/main/repositories/mongoose/ContractRepository.ts
@@ -1,112 +1,146 @@
import RepositoryBase from '../RepositoryBase';
import ContractMongoose from './models/ContractMongoose';
-import { ContractQueryFilters, ContractToCreate, LeanContract } from '../../types/models/Contract';
+import { ContractToCreate, LeanContract } from '../../types/models/Contract';
import { toPlainObject } from '../../utils/mongoose';
-class ContractRepository extends RepositoryBase {
- /**
- * Find contracts using advanced filters provided in `filters` key of queryFilters.
- * filters may contain:
- * - services: either array of service names OR object { serviceName: [versions] }
- * - plans: { serviceName: [planNames] }
- * - addOns: { serviceName: [addOnNames] }
- */
- async findByFilters(queryFilters: any) {
- const {
- username,
- firstName,
- lastName,
- email,
- page = 1,
- offset = 0,
- limit = 20,
- sort,
- order = 'asc',
- filters,
- } = queryFilters || {};
-
- const matchConditions: any[] = [];
-
- if (username) {
- matchConditions.push({ 'userContact.username': { $regex: username, $options: 'i' } });
- }
- if (firstName) {
- matchConditions.push({ 'userContact.firstName': { $regex: firstName, $options: 'i' } });
- }
- if (lastName) {
- matchConditions.push({ 'userContact.lastName': { $regex: lastName, $options: 'i' } });
- }
- if (email) {
- matchConditions.push({ 'userContact.email': { $regex: email, $options: 'i' } });
- }
+type ContractMatchPipeline = {
+ pipeline: any[];
+ matchConditions: any[];
+ page: number;
+ offset: number;
+ limit: number;
+ sort?: string;
+ order?: 'asc' | 'desc';
+};
- // We'll convert contractedServices object to array to ease matching
- const pipeline: any[] = [
- { $addFields: { contractedServicesArray: { $objectToArray: '$contractedServices' } } },
- ];
-
- if (filters) {
- // services filter
- if (filters.services) {
- const services = filters.services;
- if (Array.isArray(services)) {
- // array of service names: contractedServicesArray.k in list
- matchConditions.push({ 'contractedServicesArray.k': { $in: services.map((s: string) => s.toLowerCase()) } });
- } else if (typeof services === 'object') {
- // object mapping serviceName -> [versions]
- const perServiceMatches: any[] = [];
- for (const [serviceName, versions] of Object.entries(services)) {
- if (!Array.isArray(versions) || versions.length === 0) {
- // match any version for the service
- perServiceMatches.push({ 'contractedServicesArray': { $elemMatch: { k: serviceName.toLowerCase() } } });
- } else {
- // match if contractedServices has key serviceName and its v (value) in provided versions
- perServiceMatches.push({
- $and: [
- { 'contractedServicesArray': { $elemMatch: { k: serviceName.toLowerCase(), v: { $in: versions.map((v: string) => v.replace(/\./g, '_')) } } } },
- ],
- });
- }
- }
- if (perServiceMatches.length > 0) {
- matchConditions.push({ $or: perServiceMatches });
- }
- }
- }
+const buildMatchPipeline = (queryFilters: any): ContractMatchPipeline => {
+ const {
+ username,
+ firstName,
+ lastName,
+ email,
+ page = 1,
+ offset = 0,
+ limit = 20,
+ sort,
+ order = 'asc',
+ filters,
+ organizationId,
+ groupId,
+ } = queryFilters || {};
- // plans filter: subscriptionPlans is an object serviceName -> planName
- if (filters.plans && typeof filters.plans === 'object') {
- const perServicePlanMatches: any[] = [];
- for (const [serviceName, plans] of Object.entries(filters.plans)) {
- if (Array.isArray(plans) && plans.length > 0) {
- perServicePlanMatches.push({ [`subscriptionPlans.${serviceName.toLowerCase()}`]: { $in: plans.map((p: string) => new RegExp(`^${p}$`, 'i')) } });
+ const matchConditions: any[] = [];
+
+ if (username) {
+ matchConditions.push({ 'userContact.username': { $regex: username, $options: 'i' } });
+ }
+ if (firstName) {
+ matchConditions.push({ 'userContact.firstName': { $regex: firstName, $options: 'i' } });
+ }
+ if (lastName) {
+ matchConditions.push({ 'userContact.lastName': { $regex: lastName, $options: 'i' } });
+ }
+ if (email) {
+ matchConditions.push({ 'userContact.email': { $regex: email, $options: 'i' } });
+ }
+ if (organizationId) {
+ matchConditions.push({ organizationId: organizationId });
+ }
+ if (groupId) {
+ matchConditions.push({ groupId: groupId });
+ }
+
+ const pipeline: any[] = [
+ { $addFields: { contractedServicesArray: { $objectToArray: '$contractedServices' } } },
+ ];
+
+ if (filters) {
+ if (filters.services) {
+ const services = filters.services;
+ if (Array.isArray(services)) {
+ matchConditions.push({
+ 'contractedServicesArray.k': { $in: services.map((s: string) => s.toLowerCase()) },
+ });
+ } else if (typeof services === 'object') {
+ const perServiceMatches: any[] = [];
+ for (const [serviceName, versions] of Object.entries(services)) {
+ if (!Array.isArray(versions) || versions.length === 0) {
+ perServiceMatches.push({
+ 'contractedServicesArray': { $elemMatch: { k: serviceName.toLowerCase() } },
+ });
+ } else {
+ perServiceMatches.push({
+ $and: [
+ {
+ 'contractedServicesArray': {
+ $elemMatch: {
+ k: serviceName.toLowerCase(),
+ v: { $in: versions.map((v: string) => v.replace(/\./g, '_')) },
+ },
+ },
+ },
+ ],
+ });
}
}
- if (perServicePlanMatches.length > 0) {
- matchConditions.push({ $or: perServicePlanMatches });
+ if (perServiceMatches.length > 0) {
+ matchConditions.push({ $or: perServiceMatches });
}
}
+ }
- // addOns filter: subscriptionAddOns is object serviceName -> { addOnName: count }
- if (filters.addOns && typeof filters.addOns === 'object') {
- const perServiceAddOnMatches: any[] = [];
- for (const [serviceName, addOns] of Object.entries(filters.addOns)) {
- if (Array.isArray(addOns) && addOns.length > 0) {
- // We need to check keys of subscriptionAddOns[serviceName]
- perServiceAddOnMatches.push({
- $or: addOns.map((addOnName: string) => ({ [`subscriptionAddOns.${serviceName.toLowerCase()}.${addOnName.toLowerCase()}`]: { $exists: true } })),
- });
- }
- }
- if (perServiceAddOnMatches.length > 0) {
- matchConditions.push({ $or: perServiceAddOnMatches });
+ if (filters.plans && typeof filters.plans === 'object') {
+ const perServicePlanMatches: any[] = [];
+ for (const [serviceName, plans] of Object.entries(filters.plans)) {
+ if (Array.isArray(plans) && plans.length > 0) {
+ perServicePlanMatches.push({
+ [`subscriptionPlans.${serviceName.toLowerCase()}`]: {
+ $in: plans.map((p: string) => new RegExp(`^${p}$`, 'i')),
+ },
+ });
}
}
+ if (perServicePlanMatches.length > 0) {
+ matchConditions.push({ $or: perServicePlanMatches });
+ }
}
- if (matchConditions.length > 0) {
- pipeline.push({ $match: { $and: matchConditions } });
+ if (filters.addOns && typeof filters.addOns === 'object') {
+ const perServiceAddOnMatches: any[] = [];
+ for (const [serviceName, addOns] of Object.entries(filters.addOns)) {
+ if (Array.isArray(addOns) && addOns.length > 0) {
+ perServiceAddOnMatches.push({
+ $or: addOns.map((addOnName: string) => ({
+ [`subscriptionAddOns.${serviceName.toLowerCase()}.${addOnName.toLowerCase()}`]: {
+ $exists: true,
+ },
+ })),
+ });
+ }
+ }
+ if (perServiceAddOnMatches.length > 0) {
+ matchConditions.push({ $or: perServiceAddOnMatches });
+ }
}
+ }
+
+ if (matchConditions.length > 0) {
+ pipeline.push({ $match: { $and: matchConditions } });
+ }
+
+ return { pipeline, matchConditions, page, offset, limit, sort, order };
+};
+
+class ContractRepository extends RepositoryBase {
+ /**
+ * Find contracts using advanced filters provided in `filters` key of queryFilters.
+ * filters may contain:
+ * - services: either array of service names OR object { serviceName: [versions] }
+ * - plans: { serviceName: [planNames] }
+ * - addOns: { serviceName: [addOnNames] }
+ */
+ async findByFilters(queryFilters: any) {
+ const { pipeline, page, offset, limit, sort, order } = buildMatchPipeline(queryFilters);
pipeline.push({
$sort: {
@@ -122,6 +156,12 @@ class ContractRepository extends RepositoryBase {
return contracts.map(contract => toPlainObject(contract));
}
+ async countByFilters(queryFilters: any): Promise {
+ const { pipeline } = buildMatchPipeline(queryFilters);
+ const result = await ContractMongoose.aggregate([...pipeline, { $count: 'total' }]);
+ return result[0]?.total ?? 0;
+ }
+
async findByUserId(userId: string): Promise {
const contract = await ContractMongoose.findOne({ 'userContact.userId': userId });
return contract ? toPlainObject(contract.toJSON()) : null;
@@ -145,9 +185,65 @@ class ContractRepository extends RepositoryBase {
return contract ? toPlainObject(contract.toJSON()) : null;
}
- async bulkUpdate(contracts: LeanContract[], disable = false): Promise {
+ async changeServiceName(oldServiceName: string, newServiceName: string, organizationId: string): Promise {
+ const oldServiceKey = oldServiceName.toLowerCase();
+ const newServiceKey = newServiceName.toLowerCase();
+
+ const result = await ContractMongoose.updateMany(
+ {
+ organizationId,
+ [`contractedServices.${oldServiceKey}`]: { $exists: true }
+ },
+ [
+ {
+ $set: {
+ contractedServices: {
+ $arrayToObject: {
+ $map: {
+ input: { $objectToArray: '$contractedServices' },
+ as: 'item',
+ in: {
+ k: { $cond: [{ $eq: ['$$item.k', oldServiceKey] }, newServiceKey, '$$item.k'] },
+ v: '$$item.v'
+ }
+ }
+ }
+ },
+ subscriptionPlans: {
+ $arrayToObject: {
+ $map: {
+ input: { $objectToArray: '$subscriptionPlans' },
+ as: 'item',
+ in: {
+ k: { $cond: [{ $eq: ['$$item.k', oldServiceKey] }, newServiceKey, '$$item.k'] },
+ v: '$$item.v'
+ }
+ }
+ }
+ },
+ subscriptionAddOns: {
+ $arrayToObject: {
+ $map: {
+ input: { $objectToArray: '$subscriptionAddOns' },
+ as: 'item',
+ in: {
+ k: { $cond: [{ $eq: ['$$item.k', oldServiceKey] }, newServiceKey, '$$item.k'] },
+ v: '$$item.v'
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ );
+
+ return result.modifiedCount;
+ }
+
+ async bulkUpdate(contracts: LeanContract[], disable = false): Promise {
if (contracts.length === 0) {
- return true;
+ return 0;
}
const bulkOps = contracts.map(contract => ({
@@ -165,18 +261,13 @@ class ContractRepository extends RepositoryBase {
const result = await ContractMongoose.bulkWrite(bulkOps);
- if (result.modifiedCount === 0 && result.upsertedCount === 0) {
- throw new Error('No contracts were updated or inserted');
- }
-
- return true;
+ return result.modifiedCount;
}
- async prune(): Promise {
- const result = await ContractMongoose.deleteMany({});
- if (result.deletedCount === 0) {
- throw new Error('No contracts found to delete');
- }
+ async prune(organizationId?: string): Promise {
+ const filter = organizationId ? { organizationId } : {};
+ const result = await ContractMongoose.deleteMany(filter);
+
return result.deletedCount;
}
@@ -186,6 +277,11 @@ class ContractRepository extends RepositoryBase {
throw new Error(`Contract with userId ${userId} not found`);
}
}
+
+ async bulkDestroy(userIds: string[]): Promise {
+ const result = await ContractMongoose.deleteMany({ 'userContact.userId': { $in: userIds } });
+ return result.deletedCount || 0;
+ }
}
export default ContractRepository;
diff --git a/api/src/main/repositories/mongoose/OrganizationRepository.ts b/api/src/main/repositories/mongoose/OrganizationRepository.ts
new file mode 100644
index 0000000..54ffab7
--- /dev/null
+++ b/api/src/main/repositories/mongoose/OrganizationRepository.ts
@@ -0,0 +1,190 @@
+import { LeanApiKey, LeanOrganization, OrganizationFilter, OrganizationMember } from '../../types/models/Organization';
+import RepositoryBase from '../RepositoryBase';
+import OrganizationMongoose from './models/OrganizationMongoose';
+
+class OrganizationRepository extends RepositoryBase {
+ async find(filters: OrganizationFilter, limit?: number, offset?: number): Promise {
+ const query: any = {
+ ...(filters.name ? { name: { $regex: filters.name, $options: 'i' } } : {}),
+ ...(filters.owner ? { owner: filters.owner } : {}),
+ ...(filters.default !== undefined ? { default: filters.default } : {}),
+ };
+ const organizations = await OrganizationMongoose.find(query).skip(offset || 0).limit(limit || 10).exec();
+
+ return organizations.map(org => org.toObject() as unknown as LeanOrganization);
+ }
+
+ async count(filters: OrganizationFilter): Promise {
+ const query: any = {
+ ...(filters.name ? { name: { $regex: filters.name, $options: 'i' } } : {}),
+ ...(filters.owner ? { owner: filters.owner } : {}),
+ ...(filters.default !== undefined ? { default: filters.default } : {}),
+ };
+
+ return await OrganizationMongoose.countDocuments(query).exec();
+ }
+
+ async findById(organizationId: string): Promise {
+ try {
+ const organization = await OrganizationMongoose.findOne({ _id: organizationId })
+ .populate({
+ path: 'ownerDetails',
+ select: '-password',
+ })
+ .exec();
+
+ return organization ? (organization.toObject() as unknown as LeanOrganization) : null;
+ } catch (error) {
+ throw new Error("INVALID DATA: Invalid organization ID");
+ }
+ }
+
+ async findByOwner(owner: string): Promise {
+ const organizations = await OrganizationMongoose.find({ owner }).exec();
+
+ return organizations.map(org => {
+ const obj = org.toObject() as any;
+ return Object.assign({ id: org._id.toString() }, obj) as LeanOrganization;
+ });
+ }
+
+ async findByUser(username: string): Promise {
+ const organizations = await OrganizationMongoose.find({
+ $or: [
+ { owner: username },
+ { 'members.username': username }
+ ]
+ }).exec();
+
+ return organizations.map(org => org.toObject() as unknown as LeanOrganization);
+ }
+
+ async findByApiKey(apiKey: string): Promise {
+ const organization = await OrganizationMongoose.findOne({
+ 'apiKeys.key': apiKey,
+ })
+ .populate({
+ path: 'ownerDetails',
+ select: '-password',
+ })
+ .exec();
+
+ return organization ? (organization.toObject() as unknown as LeanOrganization) : null;
+ }
+
+ async create(organizationData: LeanOrganization): Promise {
+ const organization = await new OrganizationMongoose(organizationData).save();
+ return organization.toObject() as unknown as LeanOrganization;
+ }
+
+ async addApiKey(organizationId: string, apiKeyData: LeanApiKey): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId },
+ { $push: { apiKeys: apiKeyData } }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(
+ `ApiKey with key ${apiKeyData.key} not found in organization ${organizationId}.`
+ );
+ }
+
+ return result.modifiedCount;
+ }
+
+ async addMember(organizationId: string, organizationMember: OrganizationMember): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId },
+ { $addToSet: { members: organizationMember } }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(
+ `Member with username ${organizationMember.username} not found in organization ${organizationId}.`
+ );
+ }
+
+ return result.modifiedCount;
+ }
+
+ async updateMemberRole(organizationId: string, username: string, role: string): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId, 'members.username': username },
+ { $set: { 'members.$.role': role } }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(
+ `INVALID DATA: Member with username ${username} not found in organization ${organizationId}.`
+ );
+ }
+
+ return result.modifiedCount;
+ }
+
+ async changeOwner(organizationId: string, newOwner: string): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId },
+ { owner: newOwner }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(`Organization with id ${organizationId} not found or no changes made.`);
+ }
+
+ return result.modifiedCount;
+ }
+
+ async update(organizationId: string, updateData: any): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId },
+ { $set: updateData }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(`Organization with id ${organizationId} not found or no changes made.`);
+ }
+
+ return result.modifiedCount;
+ }
+
+ async removeApiKey(organizationId: string, apiKey: string): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId },
+ { $pull: { apiKeys: { key: apiKey } } }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(`ApiKey with key ${apiKey} not found in organization ${organizationId}.`);
+ }
+
+ return result.modifiedCount;
+ }
+
+ async removeMember(organizationId: string, username: string): Promise {
+ const result = await OrganizationMongoose.updateOne(
+ { _id: organizationId },
+ { $pull: { members: {username: username} } }
+ ).exec();
+
+ if (result.modifiedCount === 0) {
+ throw new Error(
+ `Member with username ${username} not found in organization ${organizationId}.`
+ );
+ }
+
+ return result.modifiedCount;
+ }
+
+ async delete(organizationId: string): Promise {
+ const result = await OrganizationMongoose.deleteOne({ _id: organizationId }).exec();
+
+ if (result.deletedCount === 0) {
+ throw new Error(`Organization with id ${organizationId} not found.`);
+ }
+
+ return result.deletedCount;
+ }
+}
+
+export default OrganizationRepository;
diff --git a/api/src/main/repositories/mongoose/ServiceRepository.ts b/api/src/main/repositories/mongoose/ServiceRepository.ts
index b45aaae..cdaf4ea 100644
--- a/api/src/main/repositories/mongoose/ServiceRepository.ts
+++ b/api/src/main/repositories/mongoose/ServiceRepository.ts
@@ -2,7 +2,6 @@ import RepositoryBase from '../RepositoryBase';
import PricingMongoose from './models/PricingMongoose';
import ServiceMongoose from './models/ServiceMongoose';
import { LeanService } from '../../types/models/Service';
-import { toPlainObject } from '../../utils/mongoose';
import { LeanPricing } from '../../types/models/Pricing';
export type ServiceQueryFilters = {
@@ -11,88 +10,154 @@ export type ServiceQueryFilters = {
offset?: number;
limit?: number;
order?: 'asc' | 'desc';
-}
+};
class ServiceRepository extends RepositoryBase {
- async findAll(queryFilters?: ServiceQueryFilters, disabled = false) {
+ async findAll(organizationId?: string, queryFilters?: ServiceQueryFilters, disabled = false) {
const { name, page = 1, offset = 0, limit = 20, order = 'asc' } = queryFilters || {};
-
- const services = await ServiceMongoose.find({
+
+ const query: any = {
...(name ? { name: { $regex: name, $options: 'i' } } : {}),
disabled: disabled,
- })
+ ...(organizationId ? { organizationId: organizationId } : {}),
+ };
+
+ const services = await ServiceMongoose.find(query)
.skip(offset == 0 ? (page - 1) * limit : offset)
.limit(limit)
.sort({ name: order === 'asc' ? 1 : -1 });
-
- return services.map((service) => toPlainObject(service.toJSON()));
+
+ return services.map(service => service.toObject() as unknown as LeanService);
}
- async findAllNoQueries(disabled = false, projection: any = { name: 1, activePricings: 1, archivedPricings: 1 }): Promise {
- const services = await ServiceMongoose.find({ disabled: disabled }).select(projection);
+ async findAllNoQueries(
+ organizationId?: string,
+ disabled = false,
+ projection: any = { name: 1, activePricings: 1, archivedPricings: 1 }
+ ): Promise {
+ const query: any = {
+ disabled: disabled,
+ ...(organizationId ? { organizationId: organizationId } : {}),
+ };
+ const services = await ServiceMongoose.find(query).select(projection);
- if (!services || Array.isArray(services) && services.length === 0) {
+ if (!services || (Array.isArray(services) && services.length === 0)) {
return null;
}
-
- return services.map((service) => toPlainObject(service.toJSON()));
+
+ return services.map(service => service.toObject() as unknown as LeanService);
}
- async findByName(name: string, disabled = false): Promise {
- const service = await ServiceMongoose.findOne({ name: { $regex: name, $options: 'i' }, disabled: disabled });
+ async findByName(
+ name: string,
+ organizationId: string,
+ disabled = false
+ ): Promise {
+ const query: any = {
+ name: { $regex: name, $options: 'i' },
+ disabled: disabled,
+ organizationId: organizationId,
+ };
+ const service = await ServiceMongoose.findOne(query);
if (!service) {
return null;
}
- return toPlainObject(service.toJSON());
+ return service.toObject() as unknown as LeanService;
}
- async findByNames(names: string[], disabled = false): Promise {
- const services = await ServiceMongoose.find({ name: { $in: names.map(name => new RegExp(name, 'i')) }, disabled: disabled });
- if (!services || Array.isArray(services) && services.length === 0) {
+ async findByNames(
+ names: string[],
+ organizationId: string,
+ disabled = false
+ ): Promise {
+ const query: any = {
+ name: { $in: names.map(name => new RegExp(name, 'i')) },
+ disabled: disabled,
+ organizationId: organizationId,
+ };
+ const services = await ServiceMongoose.find(query);
+ if (!services || (Array.isArray(services) && services.length === 0)) {
return null;
}
- return services.map((service) => toPlainObject(service.toJSON()));
+ return services.map(service => service.toObject() as unknown as LeanService);
}
- async findPricingsByServiceName(serviceName: string, versionsToRetrieve: string[], disabled = false): Promise {
- const pricings = await PricingMongoose.find({ _serviceName: { $regex: serviceName, $options: 'i' }, version: { $in: versionsToRetrieve } });
- if (!pricings || Array.isArray(pricings) && pricings.length === 0) {
+ async findPricingsByServiceName(
+ serviceName: string,
+ versionsToRetrieve: string[],
+ organizationId?: string,
+ disabled = false
+ ): Promise {
+ const query: any = {
+ _serviceName: { $regex: serviceName, $options: 'i' },
+ version: { $in: versionsToRetrieve },
+ _organizationId: organizationId,
+ };
+ const pricings = await PricingMongoose.find(query);
+ if (!pricings || (Array.isArray(pricings) && pricings.length === 0)) {
return null;
}
- return pricings.map((p) => toPlainObject(p.toJSON()));
+ return pricings.map(p => p.toJSON() as unknown as LeanPricing);
}
async create(data: any) {
-
const service = await ServiceMongoose.insertOne(data);
-
- return toPlainObject(service.toJSON());
+
+ return service.toObject() as unknown as LeanService;
}
- async update(name: string, data: any) {
- const service = await ServiceMongoose.findOne({ name: { $regex: name, $options: 'i' } });
+ async update(name: string, data: any, organizationId: string) {
+ const query: any = { name: { $regex: name, $options: 'i' } };
+ if (organizationId) {
+ query.organizationId = organizationId;
+ }
+
+ // 1. Separate the $set operations (update) and $unset operations (delete)
+ const $set: any = {};
+ const $unset: any = {};
+
+ Object.entries(data).forEach(([key, value]) => {
+ if (value === undefined) {
+ // If the value is undefined, add it to the delete list
+ $unset[key] = '';
+ } else {
+ // If it has a value, update it
+ $set[key] = value;
+ }
+ });
+
+ // 2. Execute the atomic update
+ // new: true returns the modified document
+ const service = await ServiceMongoose.findOneAndUpdate(
+ query,
+ { $set, $unset },
+ { new: true }
+ );
+
if (!service) {
return null;
}
- service.set(data);
- await service.save();
-
- return toPlainObject(service.toJSON());
+ return service.toObject() as unknown as LeanService;
}
- async disable(name: string) {
- const service = await ServiceMongoose.findOne({ name: { $regex: name, $options: 'i' } });
+ async disable(name: string, organizationId: string) {
+ const query: any = { name: { $regex: name, $options: 'i' }, organizationId: organizationId };
+ const service = await ServiceMongoose.findOne(query);
if (!service) {
return null;
}
// Normalize archived and active pricings to plain objects to avoid Mongoose Map cast issues
- const existingArchived = service.archivedPricings ? JSON.parse(JSON.stringify(service.archivedPricings)) : {};
- const existingActive = service.activePricings ? JSON.parse(JSON.stringify(service.activePricings)) : {};
+ const existingArchived = service.archivedPricings
+ ? JSON.parse(JSON.stringify(service.archivedPricings))
+ : {};
+ const existingActive = service.activePricings
+ ? JSON.parse(JSON.stringify(service.activePricings))
+ : {};
const mergedArchived: Record = { ...existingArchived };
@@ -114,19 +179,20 @@ class ServiceRepository extends RepositoryBase {
await service.save();
- return toPlainObject(service.toJSON());
+ return service.toObject() as unknown as LeanService;
}
- async destroy(name: string, ...args: any) {
- const result = await ServiceMongoose.deleteOne({ name: { $regex: name, $options: 'i' } });
-
+ async destroy(name: string, organizationId: string, ...args: any) {
+ const query: any = { name: { $regex: name, $options: 'i' }, organizationId: organizationId };
+ const result = await ServiceMongoose.deleteOne(query);
+
if (!result) {
return null;
}
if (result.deletedCount === 0) {
return null;
}
-
+
if (result.deletedCount === 1) {
await PricingMongoose.deleteMany({ _serviceName: name });
}
@@ -134,8 +200,12 @@ class ServiceRepository extends RepositoryBase {
return true;
}
- async prune() {
- const result = await ServiceMongoose.deleteMany({});
+ async prune(organizationId?: string): Promise {
+ const query: any = {};
+ if (organizationId) {
+ query.organizationId = organizationId;
+ }
+ const result = await ServiceMongoose.deleteMany(query);
if (result.deletedCount === 0) {
return null;
diff --git a/api/src/main/repositories/mongoose/UserRepository.ts b/api/src/main/repositories/mongoose/UserRepository.ts
index a1eb9c7..cc3522b 100644
--- a/api/src/main/repositories/mongoose/UserRepository.ts
+++ b/api/src/main/repositories/mongoose/UserRepository.ts
@@ -1,24 +1,53 @@
import { toPlainObject } from '../../utils/mongoose';
import RepositoryBase from '../RepositoryBase';
import UserMongoose from './models/UserMongoose';
-import { LeanUser, Role } from '../../types/models/User';
-import { generateApiKey } from '../../utils/users/helpers';
+import { LeanUser } from '../../types/models/User';
+import { UserRole } from '../../types/permissions';
+import { generateUserApiKey } from '../../utils/users/helpers';
class UserRepository extends RepositoryBase {
- async findByUsername(username: string) {
+
+ async find(username: string, limit: number = 10, offset: number = 0): Promise {
+ try {
+ const users = await UserMongoose.find({ username: { $regex: username, $options: 'i' } }, { password: 0 }).skip(offset).limit(limit).exec();
+ return users.map(user => user.toObject({ getters: true, virtuals: true, versionKey: false }) as unknown as LeanUser);
+ } catch (err) {
+ return [];
+ }
+ }
+
+ async count(query: string = '') {
try {
- const user = await UserMongoose.findOne({ username });
+ return await UserMongoose.countDocuments({ username: { $regex: query, $options: 'i' } });
+ } catch (err) {
+ return 0;
+ }
+ }
+
+ async findByUsername(username: string): Promise {
+ try {
+ const user = (await this.find(username))[0];
if (!user) return null;
- return toPlainObject(user.toJSON());
+
+ return user as unknown as LeanUser;
} catch (err) {
return null;
}
}
+ async findByRole(role: UserRole): Promise {
+ try {
+ const users = await UserMongoose.find({ role: role }, { password: 0 }).exec();
+ return users.map(user => user.toObject({ getters: true, virtuals: true, versionKey: false }) as unknown as LeanUser);
+ } catch (err) {
+ return [];
+ }
+ }
+
async authenticate(username: string, password: string): Promise {
- const user = await UserMongoose.findOne({ username });
+ const user = await UserMongoose.findOne({ username }).exec();
if (!user) return null;
@@ -30,7 +59,7 @@ class UserRepository extends RepositoryBase {
if (!leanUser.apiKey) {
// If the user does not have an API key, we generate one
- const newApiKey = generateApiKey();
+ const newApiKey = generateUserApiKey();
await UserMongoose.updateOne({ username }, { apiKey: newApiKey });
return leanUser;
@@ -47,11 +76,11 @@ class UserRepository extends RepositoryBase {
return toPlainObject(user.toObject());
}
- async create(userData: any) {
+ async create(userData: any): Promise {
const user = await new UserMongoose(userData).save();
const userObject = await this.findByUsername(user.username);
- return userObject;
+ return userObject!;
}
async update(username: string, userData: any) {
@@ -61,7 +90,7 @@ class UserRepository extends RepositoryBase {
})
if (!updatedUser) {
- throw new Error('User not found');
+ throw new Error('INVALID DATA: User not found');
}
return toPlainObject(updatedUser.toJSON());
@@ -70,10 +99,14 @@ class UserRepository extends RepositoryBase {
async regenerateApiKey(username: string) {
const updatedUser = await UserMongoose.findOneAndUpdate(
{ username: username },
- { apiKey: generateApiKey() },
+ { apiKey: generateUserApiKey() },
{ new: true, projection: { password: 0 } }
);
+ if (!updatedUser) {
+ throw new Error('INVALID DATA: User not found');
+ }
+
return toPlainObject(updatedUser?.toJSON()).apiKey;
}
@@ -82,16 +115,7 @@ class UserRepository extends RepositoryBase {
return result?.deletedCount === 1;
}
- async findAll() {
- try {
- const users = await UserMongoose.find({}, { password: 0 });
- return users.map(user => user.toObject({ getters: true, virtuals: true, versionKey: false }));
- } catch (err) {
- return [];
- }
- }
-
- async changeRole(username: string, role: Role) {
+ async changeRole(username: string, role: UserRole) {
return this.update(username, { role });
}
}
diff --git a/api/src/main/repositories/mongoose/models/ContractMongoose.ts b/api/src/main/repositories/mongoose/models/ContractMongoose.ts
index ac88672..b205b72 100644
--- a/api/src/main/repositories/mongoose/models/ContractMongoose.ts
+++ b/api/src/main/repositories/mongoose/models/ContractMongoose.ts
@@ -25,6 +25,8 @@ const contractSchema = new Schema(
renewalDays: { type: Number, default: 30 },
},
usageLevels: {type: Map, of: {type: Map, of: usageLevelSchema}},
+ organizationId: { type: String, ref: "Organization", required: true },
+ groupId: { type: String },
contractedServices: {type: Map, of: String},
subscriptionPlans: { type: Map, of: String },
subscriptionAddOns: { type: Map, of: {type: Map, of: Number} },
diff --git a/api/src/main/repositories/mongoose/models/OrganizationMongoose.ts b/api/src/main/repositories/mongoose/models/OrganizationMongoose.ts
new file mode 100644
index 0000000..9e62cd7
--- /dev/null
+++ b/api/src/main/repositories/mongoose/models/OrganizationMongoose.ts
@@ -0,0 +1,49 @@
+import mongoose, { Schema } from 'mongoose';
+import OrganizationApiKey from './schemas/OrganizationApiKey';
+import OrganizationUser from './schemas/OrganizationUser';
+
+
+
+const organizationSchema = new Schema(
+ {
+ name: { type: String, required: true },
+ owner: {
+ type: String,
+ ref: 'User',
+ required: true
+ },
+ default: { type: Boolean, default: false },
+ apiKeys: { type: [OrganizationApiKey], default: [] },
+ members: {
+ type: [OrganizationUser],
+ default: []
+ }
+ },
+ {
+ toObject: {
+ virtuals: true,
+ transform: function (doc, resultObject, options) {
+ delete resultObject._id;
+ delete resultObject.__v;
+ return resultObject;
+ },
+ },
+ }
+);
+
+organizationSchema.virtual('ownerDetails', {
+ ref: 'User', // El modelo donde buscar
+ localField: 'owner', // El campo en Organization (que tiene el username)
+ foreignField: 'username', // El campo en User donde debe buscar ese valor
+ justOne: true // Queremos un objeto, no un array
+});
+
+// Adding indexes
+organizationSchema.index({ name: 1 });
+organizationSchema.index({ 'apiKeys.key': 1 }, { sparse: true });
+organizationSchema.index({ members: 1 });
+
+const organizationModel = mongoose.model('Organization', organizationSchema, 'organizations');
+
+export default organizationModel;
+
diff --git a/api/src/main/repositories/mongoose/models/PricingMongoose.ts b/api/src/main/repositories/mongoose/models/PricingMongoose.ts
index 53d75ed..0eec89c 100644
--- a/api/src/main/repositories/mongoose/models/PricingMongoose.ts
+++ b/api/src/main/repositories/mongoose/models/PricingMongoose.ts
@@ -7,6 +7,7 @@ import AddOn from './schemas/AddOn';
const pricingSchema = new Schema(
{
_serviceName: { type: String },
+ _organizationId: { type: String },
version: { type: String, required: true },
currency: { type: String, required: true },
createdAt: { type: Date, required: true, default: Date.now },
@@ -46,8 +47,15 @@ pricingSchema.virtual('service', {
justOne: true,
});
+pricingSchema.virtual('organization', {
+ ref: 'Organization',
+ localField: '_organizationId',
+ foreignField: '_id',
+ justOne: true,
+});
+
// Adding unique index for [name, owner, version]
-pricingSchema.index({ _serviceName: 1, version: 1 }, { unique: true });
+pricingSchema.index({ _serviceName: 1, version: 1, _organizationId: 1 }, { unique: true });
const pricingModel = mongoose.model('Pricing', pricingSchema, 'pricings');
diff --git a/api/src/main/repositories/mongoose/models/ServiceMongoose.ts b/api/src/main/repositories/mongoose/models/ServiceMongoose.ts
index 172d24d..ad7b702 100644
--- a/api/src/main/repositories/mongoose/models/ServiceMongoose.ts
+++ b/api/src/main/repositories/mongoose/models/ServiceMongoose.ts
@@ -13,6 +13,7 @@ const pricingDataSchema = new Schema(
const serviceSchema = new Schema(
{
name: { type: String, required: true },
+ organizationId: { type: String, ref: "Organization", required: true },
disabled: { type: Boolean, default: false },
activePricings: {type: Map, of: pricingDataSchema},
archivedPricings: {type: Map, of: pricingDataSchema}
@@ -38,7 +39,7 @@ serviceSchema.pre('save', function (next) {
});
// Adding unique index for [name, owner, version]
-serviceSchema.index({ name: 1 }, { unique: true });
+serviceSchema.index({ name: 1, organizationId: 1 }, { unique: true });
const serviceModel = mongoose.model('Service', serviceSchema, 'services');
diff --git a/api/src/main/repositories/mongoose/models/UserMongoose.ts b/api/src/main/repositories/mongoose/models/UserMongoose.ts
index 2d3ce9b..6e554bc 100644
--- a/api/src/main/repositories/mongoose/models/UserMongoose.ts
+++ b/api/src/main/repositories/mongoose/models/UserMongoose.ts
@@ -1,7 +1,7 @@
import bcrypt from 'bcryptjs';
import mongoose, { Document, Schema } from 'mongoose';
-import { generateApiKey, hashPassword } from '../../../utils/users/helpers';
-import { Role, USER_ROLES } from '../../../types/models/User';
+import { generateUserApiKey, hashPassword } from '../../../utils/users/helpers';
+import { UserRole, USER_ROLES } from '../../../types/permissions';
const userSchema = new Schema({
username: {
@@ -53,7 +53,7 @@ userSchema.pre('save', async function(next) {
// If there's no API Key, we generate one
if (!user.apiKey) {
- user.apiKey = generateApiKey();
+ user.apiKey = generateUserApiKey();
}
next();
@@ -66,7 +66,7 @@ export interface UserDocument extends Document {
username: string;
password: string;
apiKey: string;
- role: Role;
+ role: UserRole;
verifyPassword: (password: string) => Promise;
}
diff --git a/api/src/main/repositories/mongoose/models/schemas/OrganizationApiKey.ts b/api/src/main/repositories/mongoose/models/schemas/OrganizationApiKey.ts
new file mode 100644
index 0000000..f88e7e4
--- /dev/null
+++ b/api/src/main/repositories/mongoose/models/schemas/OrganizationApiKey.ts
@@ -0,0 +1,19 @@
+import { Schema } from 'mongoose';
+
+const organizationApiKeySchema = new Schema(
+ {
+ key: {
+ type: String,
+ required: true,
+ },
+ scope: {
+ type: String,
+ enum: ['ALL', 'MANAGEMENT', 'EVALUATION'],
+ required: true,
+ default: 'EVALUATION',
+ },
+ },
+ { _id: false }
+);
+
+export default organizationApiKeySchema;
diff --git a/api/src/main/repositories/mongoose/models/schemas/OrganizationUser.ts b/api/src/main/repositories/mongoose/models/schemas/OrganizationUser.ts
new file mode 100644
index 0000000..0c8201c
--- /dev/null
+++ b/api/src/main/repositories/mongoose/models/schemas/OrganizationUser.ts
@@ -0,0 +1,20 @@
+import { Schema } from 'mongoose';
+
+const organizationUserSchema = new Schema(
+ {
+ username: {
+ type: String,
+ ref: 'User',
+ required: true,
+ },
+ role: {
+ type: String,
+ enum: ['ADMIN', 'MANAGER', 'EVALUATOR'],
+ required: true,
+ default: 'EVALUATOR',
+ },
+ },
+ { _id: false }
+);
+
+export default organizationUserSchema;
diff --git a/api/src/main/routes/ContractRoutes.ts b/api/src/main/routes/ContractRoutes.ts
index 7d60277..8a68bb3 100644
--- a/api/src/main/routes/ContractRoutes.ts
+++ b/api/src/main/routes/ContractRoutes.ts
@@ -3,35 +3,96 @@ import express from 'express';
import ContractController from '../controllers/ContractController';
import * as ContractValidator from '../controllers/validation/ContractValidation';
import { handleValidation } from '../middlewares/ValidationHandlingMiddleware';
+import { hasPermission, memberRole } from '../middlewares/AuthMiddleware';
const loadFileRoutes = function (app: express.Application) {
const contractController = new ContractController();
const baseUrl = process.env.BASE_URL_PATH || '/api/v1';
+ app
+ .route(baseUrl + '/organizations/:organizationId/contracts')
+ .get(
+ memberRole,
+ hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']),
+ contractController.index
+ )
+ .post(
+ memberRole,
+ hasPermission(['OWNER', 'ADMIN', 'MANAGER']),
+ ContractValidator.create,
+ handleValidation,
+ contractController.create
+ )
+ .put(
+ memberRole,
+ hasPermission(['OWNER', 'ADMIN', 'MANAGER']),
+ ContractValidator.novate,
+ handleValidation,
+ contractController.novateByGroupId
+ )
+ .delete(memberRole, hasPermission(['OWNER', 'ADMIN']), contractController.prune);
+
+ app
+ .route(baseUrl + '/organizations/:organizationId/contracts/:userId')
+ .get(
+ memberRole,
+ hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']),
+ contractController.show
+ )
+ .put(
+ memberRole,
+ hasPermission(['OWNER', 'ADMIN', 'MANAGER']),
+ ContractValidator.novate,
+ handleValidation,
+ contractController.novate
+ )
+ .delete(memberRole, hasPermission(['OWNER', 'ADMIN']), contractController.destroy);
+
app
.route(baseUrl + '/contracts')
.get(contractController.index)
.post(ContractValidator.create, handleValidation, contractController.create)
+ .put(ContractValidator.novate, handleValidation, contractController.novateByGroupId)
.delete(contractController.prune);
+ app
+ .route(baseUrl + '/contracts/billingPeriod')
+ .put(
+ ContractValidator.novateBillingPeriod,
+ handleValidation,
+ contractController.novateBillingPeriodByGroupId
+ );
+
app
.route(baseUrl + '/contracts/:userId')
.get(contractController.show)
.put(ContractValidator.novate, handleValidation, contractController.novate)
.delete(contractController.destroy);
-
- app
+
+ app
.route(baseUrl + '/contracts/:userId/usageLevels')
- .put(ContractValidator.incrementUsageLevels, handleValidation, contractController.resetUsageLevels);
+ .put(
+ ContractValidator.incrementUsageLevels,
+ handleValidation,
+ contractController.resetUsageLevels
+ );
- app
+ app
.route(baseUrl + '/contracts/:userId/userContact')
- .put(ContractValidator.novateUserContact, handleValidation, contractController.novateUserContact);
+ .put(
+ ContractValidator.novateUserContact,
+ handleValidation,
+ contractController.novateUserContact
+ );
- app
+ app
.route(baseUrl + '/contracts/:userId/billingPeriod')
- .put(ContractValidator.novateBillingPeriod, handleValidation, contractController.novateBillingPeriod);
+ .put(
+ ContractValidator.novateBillingPeriod,
+ handleValidation,
+ contractController.novateBillingPeriod
+ );
};
export default loadFileRoutes;
diff --git a/api/src/main/routes/OrganizationRoutes.ts b/api/src/main/routes/OrganizationRoutes.ts
new file mode 100644
index 0000000..37d08d7
--- /dev/null
+++ b/api/src/main/routes/OrganizationRoutes.ts
@@ -0,0 +1,77 @@
+import express from 'express';
+
+import * as OrganizationValidation from '../controllers/validation/OrganizationValidation';
+import { handleValidation } from '../middlewares/ValidationHandlingMiddleware';
+import OrganizationController from '../controllers/OrganizationController';
+import { hasOrgRole, isOrgOwner } from '../middlewares/ApiKeyAuthMiddleware';
+import { memberRole } from '../middlewares/AuthMiddleware';
+
+const loadFileRoutes = function (app: express.Application) {
+ const organizationController = new OrganizationController();
+
+ const baseUrl = process.env.BASE_URL_PATH || '/api/v1';
+
+ // Public route for authentication (does not require API Key)
+ app
+ .route(`${baseUrl}/organizations/`)
+ .get(organizationController.getAll)
+ .post(OrganizationValidation.create, handleValidation, organizationController.create);
+
+ app
+ .route(`${baseUrl}/organizations/:organizationId`)
+ .get(OrganizationValidation.getById, handleValidation, organizationController.getById)
+ .put(OrganizationValidation.update, handleValidation, isOrgOwner, organizationController.update)
+ .delete(
+ OrganizationValidation.getById,
+ handleValidation,
+ isOrgOwner,
+ organizationController.delete
+ );
+
+ app
+ .route(`${baseUrl}/organizations/:organizationId/members`)
+ .post(
+ OrganizationValidation.getById,
+ OrganizationValidation.addMember,
+ handleValidation,
+ hasOrgRole(['OWNER', 'ADMIN', 'MANAGER']),
+ organizationController.addMember
+ );
+
+ app
+ .route(`${baseUrl}/organizations/:organizationId/members/:username`)
+ .put(
+ OrganizationValidation.getById,
+ OrganizationValidation.updateMemberRole,
+ handleValidation,
+ memberRole,
+ hasOrgRole(['OWNER', 'ADMIN', 'MANAGER']),
+ organizationController.updateMemberRole
+ )
+ .delete(
+ OrganizationValidation.getById,
+ handleValidation,
+ memberRole,
+ organizationController.removeMember
+ );
+
+ app
+ .route(`${baseUrl}/organizations/:organizationId/api-keys`)
+ .post(
+ OrganizationValidation.getById,
+ handleValidation,
+ hasOrgRole(['OWNER', 'ADMIN', 'MANAGER']),
+ organizationController.addApiKey
+ );
+
+ app
+ .route(`${baseUrl}/organizations/:organizationId/api-keys/:apiKey`)
+ .delete(
+ OrganizationValidation.getById,
+ handleValidation,
+ hasOrgRole(['OWNER', 'ADMIN', 'MANAGER']),
+ organizationController.removeApiKey
+ );
+};
+
+export default loadFileRoutes;
diff --git a/api/src/main/routes/ServiceRoutes.ts b/api/src/main/routes/ServiceRoutes.ts
index 68cf3e7..46395aa 100644
--- a/api/src/main/routes/ServiceRoutes.ts
+++ b/api/src/main/routes/ServiceRoutes.ts
@@ -5,6 +5,7 @@ import * as ServiceValidator from '../controllers/validation/ServiceValidation';
import * as PricingValidator from '../controllers/validation/PricingValidation';
import { handlePricingUpload } from '../middlewares/FileHandlerMiddleware';
import { handleValidation } from '../middlewares/ValidationHandlingMiddleware';
+import { memberRole, hasPermission } from '../middlewares/AuthMiddleware';
const loadFileRoutes = function (app: express.Application) {
const serviceController = new ServiceController();
@@ -12,6 +13,39 @@ const loadFileRoutes = function (app: express.Application) {
const baseUrl = process.env.BASE_URL_PATH || '/api/v1';
+ // ============================================
+ // Organization-scoped routes (User API Keys)
+ // Accessible to authenticated users
+ // ============================================
+
+ app
+ .route(baseUrl + '/organizations/:organizationId/services')
+ .get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.index)
+ .post(memberRole, hasPermission(['OWNER','ADMIN', 'MANAGER']), upload, serviceController.create)
+ .delete(memberRole, hasPermission(['OWNER','ADMIN']), serviceController.prune);
+
+ app
+ .route(baseUrl + '/organizations/:organizationId/services/:serviceName')
+ .get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.show)
+ .put(memberRole, hasPermission(['OWNER','ADMIN', 'MANAGER']), ServiceValidator.update, handleValidation, serviceController.update)
+ .delete(memberRole, hasPermission(['OWNER','ADMIN']), serviceController.disable);
+
+ app
+ .route(baseUrl + '/organizations/:organizationId/services/:serviceName/pricings')
+ .get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.indexPricings)
+ .post(memberRole, hasPermission(['OWNER','ADMIN', 'MANAGER']), upload, serviceController.addPricingToService);
+
+ app
+ .route(baseUrl + '/organizations/:organizationId/services/:serviceName/pricings/:pricingVersion')
+ .get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.showPricing)
+ .put(memberRole, hasPermission(['OWNER','ADMIN', 'MANAGER']), PricingValidator.updateAvailability, handleValidation, serviceController.updatePricingAvailability)
+ .delete(memberRole, hasPermission(['OWNER','ADMIN']), serviceController.destroyPricing);
+
+ // ============================================
+ // Direct service routes (Organization API Keys)
+ // Accessible to organization API keys only
+ // ============================================
+
app
.route(baseUrl + '/services')
.get(serviceController.index)
diff --git a/api/src/main/routes/UserRoutes.ts b/api/src/main/routes/UserRoutes.ts
index cbc7702..1a2b86b 100644
--- a/api/src/main/routes/UserRoutes.ts
+++ b/api/src/main/routes/UserRoutes.ts
@@ -18,6 +18,11 @@ const loadFileRoutes = function (app: express.Application) {
userController.authenticate
);
+ // Get current user info (requires API Key)
+ app
+ .route(`${baseUrl}/users/me`)
+ .get(userController.getCurrentUser);
+
// Protected routes (require API Key and appropriate permissions)
app
.route(`${baseUrl}/users`)
diff --git a/api/src/main/services/CacheService.ts b/api/src/main/services/CacheService.ts
index 55da7c1..36ca190 100644
--- a/api/src/main/services/CacheService.ts
+++ b/api/src/main/services/CacheService.ts
@@ -11,6 +11,31 @@ class CacheService {
this.redisClient = client;
}
+ // --- Serialization Helpers ---
+
+ // Transforms Maps into serializable objects
+ private replacer(key: string, value: any) {
+ if (value instanceof Map) {
+ return {
+ _dataType: 'Map', // Marca especial
+ value: Array.from(value.entries()), // Guardamos como array de arrays para preservar tipos de llaves
+ };
+ }
+ return value;
+ }
+
+ // If encountering the special Map marker, reconstruct the Map
+ private reviver(key: string, value: any) {
+ if (typeof value === 'object' && value !== null) {
+ if (value._dataType === 'Map') {
+ return new Map(value.value);
+ }
+ }
+ return value;
+ }
+
+ // --------------------------------
+
async get(key: string) {
if (!this.redisClient) {
throw new Error('Redis client not initialized');
@@ -18,7 +43,8 @@ class CacheService {
const value = await this.redisClient?.get(key.toLowerCase());
- return value ? JSON.parse(value) : null;
+ // AÑADIDO: Pasamos this.reviver como segundo argumento
+ return value ? JSON.parse(value, this.reviver) : null;
}
async set(key: string, value: any, expirationInSeconds: number = 300, replaceIfExists: boolean = false) {
@@ -26,12 +52,17 @@ class CacheService {
throw new Error('Redis client not initialized');
}
+ // AÑADIDO: Serializamos usando el replacer para comparar y para guardar
+ const stringValue = JSON.stringify(value, this.replacer);
+
const previousValue = await this.redisClient?.get(key.toLowerCase());
- if (previousValue && previousValue !== JSON.stringify(value) && !replaceIfExists) {
+
+ // Comparamos contra el stringValue generado con nuestro replacer
+ if (previousValue && previousValue !== stringValue && !replaceIfExists) {
throw new Error('Value already exists in cache, please use a different key.');
}
- await this.redisClient?.set(key.toLowerCase(), JSON.stringify(value), {
+ await this.redisClient?.set(key.toLowerCase(), stringValue, {
EX: expirationInSeconds,
});
}
@@ -41,10 +72,14 @@ class CacheService {
throw new Error('Redis client not initialized');
}
- // Retrieve all keys (note: use with caution on large databases)
- const allKeys = await this.redisClient.keys(keyLocationPattern);
+ const normalizedPattern = keyLocationPattern.toLowerCase().replace(/\*\*/g, '*');
+ const keys: string[] = [];
+
+ for await (const key of this.redisClient.scanIterator({ MATCH: normalizedPattern })) {
+ keys.push(key as string);
+ }
- return allKeys;
+ return keys;
}
async del(key: string) {
@@ -53,7 +88,7 @@ class CacheService {
}
if (key.endsWith('.*')) {
- const pattern = key.toLowerCase().slice(0, -2); // Remove the ".*" suffix
+ const pattern = key.toLowerCase().slice(0, -2);
const keysToDelete = await this.redisClient.keys(`${pattern}*`);
if (keysToDelete.length > 0) {
await this.redisClient.del(keysToDelete);
@@ -62,6 +97,42 @@ class CacheService {
await this.redisClient.del(key.toLowerCase());
}
}
+
+ async delMany(keys: string[]): Promise {
+ if (!this.redisClient) {
+ throw new Error('Redis client not initialized');
+ }
+
+ if (keys.length === 0) {
+ return 0;
+ }
+
+ const keysToDelete: Set = new Set();
+
+ // Separate keys with patterns from exact keys
+ const patternKeys = keys.filter(k => k.endsWith('.*'));
+ const exactKeys = keys.filter(k => !k.endsWith('.*')).map(k => k.toLowerCase());
+
+ // Process exact keys
+ exactKeys.forEach(key => keysToDelete.add(key));
+
+ // Process pattern keys in a single batch
+ if (patternKeys.length > 0) {
+ const patterns = patternKeys.map(k => k.toLowerCase().slice(0, -2));
+
+ for (const pattern of patterns) {
+ const matchedKeys = await this.redisClient.keys(`${pattern}*`);
+ matchedKeys.forEach(k => keysToDelete.add(k));
+ }
+ }
+
+ // Delete all matched keys in a single operation
+ if (keysToDelete.size > 0) {
+ return await this.redisClient.del(Array.from(keysToDelete));
+ }
+
+ return 0;
+ }
}
-export default CacheService;
+export default CacheService;
\ No newline at end of file
diff --git a/api/src/main/services/ContractService.ts b/api/src/main/services/ContractService.ts
index 3a5a10c..5fff6f7 100644
--- a/api/src/main/services/ContractService.ts
+++ b/api/src/main/services/ContractService.ts
@@ -9,13 +9,23 @@ import {
import ContractRepository from '../repositories/mongoose/ContractRepository';
import { validateContractQueryFilters } from './validation/ContractServiceValidation';
import ServiceService from './ServiceService';
-import { LeanPricing } from '../types/models/Pricing';
+import { LeanPricing, LeanUsageLimit } from '../types/models/Pricing';
import { addDays, isAfter } from 'date-fns';
import { isSubscriptionValid } from '../controllers/validation/ContractValidation';
import { performNovation } from '../utils/contracts/novation';
import CacheService from './CacheService';
-import { addPeriodToDate, convertKeysToLowercase, escapeVersion, resetEscapeVersion } from '../utils/helpers';
-import { generateUsageLevels, resetEscapeContractedServiceVersions } from '../utils/contracts/helpers';
+import {
+ addPeriodToDate,
+ convertKeysToLowercase,
+ escapeVersion,
+ resetEscapeVersion,
+} from '../utils/helpers';
+import {
+ generateUsageLevels,
+ resetEscapeContractedServiceVersions,
+} from '../utils/contracts/helpers';
+import { query } from 'express';
+import { LeanUser } from '../types/models/User';
class ContractService {
private readonly contractRepository: ContractRepository;
@@ -28,7 +38,7 @@ class ContractService {
this.cacheService = container.resolve('cacheService');
}
- async index(queryParams: any) {
+ async index(queryParams: any, organizationId?: string): Promise {
const errors = validateContractQueryFilters(queryParams);
if (errors.length > 0) {
@@ -37,17 +47,34 @@ class ContractService {
);
}
+ queryParams.organizationId = organizationId;
+
const contracts: LeanContract[] = await this.contractRepository.findByFilters(queryParams);
for (const contract of contracts) {
- contract.contractedServices = resetEscapeContractedServiceVersions(contract.contractedServices);
+ contract.contractedServices = resetEscapeContractedServiceVersions(
+ contract.contractedServices
+ );
}
return contracts;
}
- async show(userId: string): Promise {
+ async count(queryParams: any, organizationId?: string): Promise {
+ const errors = validateContractQueryFilters(queryParams);
+
+ if (errors.length > 0) {
+ throw new Error(
+ 'Errors where found during validation of query params: ' + errors.join(' | ')
+ );
+ }
+
+ queryParams.organizationId = organizationId;
+ return this.contractRepository.countByFilters(queryParams);
+ }
+
+ async show(userId: string): Promise {
let contract = await this.cacheService.get(`contracts.${userId}`);
if (!contract) {
@@ -70,18 +97,27 @@ class ContractService {
throw new Error('Invalid request: Missing userContact.userId');
}
+ if (!contractData.organizationId) {
+ throw new Error('INVALID DATA: Missing organizationId');
+ }
+
const existingContract = await this.contractRepository.findByUserId(
contractData.userContact.userId
);
if (existingContract) {
throw new Error(
- `Invalid request: Contract for user ${contractData.userContact.userId} already exists`
+ `CONFLICT: Contract for user ${contractData.userContact.userId} already exists`
);
}
- const servicesKeys = Object.keys(contractData.contractedServices || {}).map((key) => key.toLowerCase());
- const services = await this.serviceService.indexByNames(servicesKeys);
+ const servicesKeys = Object.keys(contractData.contractedServices || {}).map(key =>
+ key.toLowerCase()
+ );
+ const services = await this.serviceService.indexByNames(
+ servicesKeys,
+ contractData.organizationId
+ );
if (!services || services.length === 0) {
throw new Error(`Invalid contract: Services not found: ${servicesKeys.join(', ')}`);
@@ -89,22 +125,20 @@ class ContractService {
if (services && servicesKeys.length !== services.length) {
const missingServices = servicesKeys.filter(
- (key) => !services.some((service) => service.name.toLowerCase() === key.toLowerCase())
+ key => !services.some(service => service.name.toLowerCase() === key.toLowerCase())
);
throw new Error(`Invalid contract: Services not found: ${missingServices.join(', ')}`);
}
for (const serviceName in contractData.contractedServices) {
const pricingVersion = escapeVersion(contractData.contractedServices[serviceName]);
- const service = services.find(
- (s) => s.name.toLowerCase() === serviceName.toLowerCase()
- );
+ const service = services.find(s => s.name.toLowerCase() === serviceName.toLowerCase());
if (!service) {
throw new Error(`Invalid contract: Services not found: ${serviceName}`);
}
- if (!Object.keys(service.activePricings).includes(pricingVersion)) {
+ if (!service.activePricings.get(pricingVersion)) {
throw new Error(
`Invalid contract: Pricing version ${pricingVersion} for service ${serviceName} not found`
);
@@ -112,7 +146,7 @@ class ContractService {
contractData.contractedServices[serviceName] = escapeVersion(pricingVersion); // Ensure the version is stored correctly
}
-
+
const startDate = new Date();
const renewalDays = contractData.billingPeriod?.renewalDays ?? 30; // Default to 30 days if not provided
const endDate = addDays(new Date(startDate), renewalDays);
@@ -130,23 +164,30 @@ class ContractService {
autoRenew: contractData.billingPeriod?.autoRenew ?? false,
renewalDays: renewalDays,
},
- usageLevels: (await this._createUsageLevels(contractData.contractedServices)) || {},
+ usageLevels:
+ (await this._createUsageLevels(
+ contractData.contractedServices,
+ contractData.organizationId
+ )) || {},
history: [],
};
try {
- await isSubscriptionValid({
- contractedServices: contractData.contractedServices,
- subscriptionPlans: contractData.subscriptionPlans,
- subscriptionAddOns: contractData.subscriptionAddOns,
- });
+ await isSubscriptionValid(
+ {
+ contractedServices: contractData.contractedServices,
+ subscriptionPlans: contractData.subscriptionPlans,
+ subscriptionAddOns: contractData.subscriptionAddOns,
+ },
+ contractData.organizationId
+ );
} catch (error) {
throw new Error(`Invalid subscription: ${error}`);
}
const contract = await this.contractRepository.create(contractDataToCreate);
-
+
contract.contractedServices = resetEscapeContractedServiceVersions(contract.contractedServices);
-
+
await this.cacheService.set(`contracts.${contract.userContact.userId}`, contract, 3600, true); // Cache for 1 hour
return contract;
@@ -163,21 +204,53 @@ class ContractService {
throw new Error(`Contract with userId ${userId} not found`);
}
+ await isSubscriptionValid(newSubscription, contract.organizationId);
+
const newContract = performNovation(contract, newSubscription);
const result = await this.contractRepository.update(userId, newContract);
-
+
if (!result) {
throw new Error(`Failed to update contract for userId ${userId}`);
}
-
+
result.contractedServices = resetEscapeContractedServiceVersions(result.contractedServices);
-
+
await this.cacheService.set(`contracts.${userId}`, result, 3600, true); // Cache for 1 hour
- await this.cacheService.del(`features.${userId}.*`)
+ await this.cacheService.del(`features.${userId}.*`);
return result;
}
+
+ async novateByGroupId(groupId: string, organizationId: string, newSubscription: any): Promise {
+ const contracts = await this.index({ groupId }, organizationId);
+
+ if (!contracts || contracts.length === 0) {
+ throw new Error(`INVALID DATA: No contracts found with groupId ${groupId} within organization ${organizationId}`);
+ }
+
+ const updatedContracts: LeanContract[] = [];
+
+ for (const contract of contracts) {
+ await isSubscriptionValid(newSubscription, contract.organizationId);
+ const newContract = performNovation(contract, newSubscription);
+ updatedContracts.push(newContract);
+ }
+
+ const result = await this.contractRepository.bulkUpdate(updatedContracts);
+
+ if (!result) {
+ throw new Error(`Failed to update contracts for groupId ${groupId} within organization ${organizationId}`);
+ }
+
+ const contractsToReturn = await this.index({ groupId }, organizationId);
+
+ for (const contract of contractsToReturn) {
+ contract.contractedServices = resetEscapeContractedServiceVersions(contract.contractedServices);
+ }
+
+ return contractsToReturn;
+ }
async renew(userId: string): Promise {
let contract = await this.cacheService.get(`contracts.${userId}`);
@@ -209,9 +282,9 @@ class ContractService {
}
result.contractedServices = resetEscapeContractedServiceVersions(result.contractedServices);
-
+
await this.cacheService.set(`contracts.${userId}`, result, 3600, true); // Cache for 1 hour
- await this.cacheService.del(`features.${userId}.*`)
+ await this.cacheService.del(`features.${userId}.*`);
return result;
}
@@ -242,9 +315,9 @@ class ContractService {
}
result.contractedServices = resetEscapeContractedServiceVersions(result.contractedServices);
-
+
await this.cacheService.set(`contracts.${userId}`, result, 3600, true); // Cache for 1 hour
- await this.cacheService.del(`features.${userId}.*`)
+ await this.cacheService.del(`features.${userId}.*`);
return result;
}
@@ -279,12 +352,53 @@ class ContractService {
}
result.contractedServices = resetEscapeContractedServiceVersions(result.contractedServices);
-
+
await this.cacheService.set(`contracts.${userId}`, result, 3600, true); // Cache for 1 hour
- await this.cacheService.del(`features.${userId}.*`)
+ await this.cacheService.del(`features.${userId}.*`);
return result;
}
+
+ async novateBillingPeriodByGroupId(
+ groupId: string,
+ organizationId: string,
+ newBillingPeriod: { endDate: Date; autoRenew: boolean; renewalDays: number }
+ ): Promise {
+
+ const contracts = await this.index({ groupId: groupId }, organizationId);
+
+ if (!contracts || contracts.length === 0) {
+ throw new Error(`INVALID DATA: Contract with groupId ${groupId} not found within organization ${organizationId}`);
+ }
+
+ const updatedContracts: LeanContract[] = [];
+
+ for (const contract of contracts) {
+ if (new Date(newBillingPeriod.endDate) < new Date(contract.billingPeriod.startDate)) {
+ throw new Error(`INVALID DATA: Error updating billing period for contract of user ${contract.userContact.userId}. End date cannot be before the start date.`);
+ }
+ contract.billingPeriod = {
+ ...contract.billingPeriod,
+ ...newBillingPeriod,
+ };
+
+ updatedContracts.push(contract);
+ }
+
+ const result = await this.contractRepository.bulkUpdate(updatedContracts);
+
+ if (!result) {
+ throw new Error(`Failed to update contracts for groupId ${groupId} within organization ${organizationId}`);
+ }
+
+ const contractsToReturn = await this.index({ groupId: groupId }, organizationId);
+
+ for (const contract of contractsToReturn) {
+ contract.contractedServices = resetEscapeContractedServiceVersions(contract.contractedServices);
+ }
+
+ return contractsToReturn;
+ }
async resetUsageLevels(
userId: string,
@@ -302,9 +416,13 @@ class ContractService {
}
if (queryParams.usageLimit) {
- await this._resetUsageLimitUsageLevels(contract, queryParams.usageLimit);
+ await this._resetUsageLimitUsageLevels(
+ contract,
+ queryParams.usageLimit,
+ contract.organizationId
+ );
} else if (queryParams.reset) {
- await this._resetUsageLevels(contract, queryParams.renewableOnly);
+ await this._resetUsageLevels(contract, queryParams.renewableOnly, contract.organizationId);
} else if (usageLevelsIncrements) {
for (const serviceName in usageLevelsIncrements) {
for (const usageLimit in usageLevelsIncrements[serviceName]) {
@@ -324,11 +442,13 @@ class ContractService {
throw new Error(`Failed to update contract for userId ${userId}`);
}
- updatedContract.contractedServices = resetEscapeContractedServiceVersions(updatedContract.contractedServices);
+ updatedContract.contractedServices = resetEscapeContractedServiceVersions(
+ updatedContract.contractedServices
+ );
await this.cacheService.set(`contracts.${userId}`, updatedContract, 3600, true); // Cache for 1 hour
- await this.cacheService.del(`features.${userId}.*`)
-
+ await this.cacheService.del(`features.${userId}.*`);
+
return updatedContract;
}
@@ -366,7 +486,6 @@ class ContractService {
}
await this.cacheService.set(`contracts.${userId}`, updatedContract, 3600, true); // Cache for 1 hour
-
} else {
throw new Error(`Usage level ${usageLimit} not found in contract for userId ${userId}`);
}
@@ -374,7 +493,7 @@ class ContractService {
async _revertExpectedConsumption(
userId: string,
- usageLimitId: string,
+ featureId: string,
latest: boolean = false
): Promise {
let contract = await this.cacheService.get(`contracts.${userId}`);
@@ -387,41 +506,61 @@ class ContractService {
throw new Error(`Contract with userId ${userId} not found`);
}
- const serviceName: string = usageLimitId.split('.')[0];
- const usageLimit: string = usageLimitId.split('.')[1];
+ const serviceName: string = featureId.split('-')[0].toLowerCase();
+ const featureName: string = featureId.split('-')[1];
- if (contract.usageLevels[serviceName][usageLimit]) {
- const previousCachedValue = await this._getCachedUsageLevel(
- userId,
- serviceName,
- usageLimit,
- latest
- );
+ const pricing: LeanPricing = await this.serviceService.showPricing(
+ serviceName,
+ contract.contractedServices[serviceName],
+ contract.organizationId
+ );
- if (!previousCachedValue) {
+ const affectedUsageLimits = Object.values(pricing.usageLimits || {})
+ .filter((usageLimit: LeanUsageLimit) => usageLimit.linkedFeatures?.includes(featureName))
+ .map(ul => ul.name!);
+
+ for (const usageLimitName of affectedUsageLimits) {
+ if (contract.usageLevels[serviceName][usageLimitName]) {
+ const previousCachedValue = await this._getCachedUsageLevel(
+ userId,
+ serviceName,
+ usageLimitName,
+ latest
+ );
+
+ if (previousCachedValue === null || previousCachedValue === undefined) {
+ console.log(
+ `WARNING: No previous cached value found for limit: ${serviceName}-${usageLimitName}. Unable to perform revert operation.`
+ );
+ }else{
+ contract.usageLevels[serviceName][usageLimitName].consumed = previousCachedValue;
+ }
+ } else {
throw new Error(
- `No previous cached value found for user ${contract.userContact.username}, serviceName ${serviceName}, usageLimit ${usageLimit}. This may be caused because the usage level update that you are trying to revert was made more that 2 minutes ago.`
+ `Usage level ${serviceName}-${usageLimitName} not found in contract for user ${contract.userContact.username}`
);
}
+ }
- contract.usageLevels[serviceName][usageLimit].consumed += previousCachedValue;
+ const updatedContract = await this.contractRepository.update(userId, contract);
- const updatedContract = await this.contractRepository.update(userId, contract);
+ if (!updatedContract) {
+ throw new Error(`Failed to update contract for userId ${userId}`);
+ }
- if (!updatedContract) {
- throw new Error(`Failed to update contract for userId ${userId}`);
- }
+ await this.cacheService.set(`contracts.${userId}`, updatedContract, 3600, true); // Cache for 1 hour
+ }
- await this.cacheService.set(`contracts.${userId}`, updatedContract, 3600, true); // Cache for 1 hour
- } else {
- throw new Error(
- `Usage level ${usageLimit} not found in contract for user ${contract.userContact.username}`
- );
+ async prune(organizationId?: string, reqUser?: LeanUser): Promise {
+ if (
+ reqUser &&
+ reqUser.role !== 'ADMIN' &&
+ !['OWNER', 'ADMIN'].includes(reqUser.orgRole ?? '')
+ ) {
+ throw new Error('PERMISSION ERROR: Only ADMIN users can prune organization contracts');
}
- }
- async prune(): Promise {
- const result: number = await this.contractRepository.prune();
+ const result: number = await this.contractRepository.prune(organizationId);
return result;
}
@@ -449,7 +588,7 @@ class ContractService {
latest: boolean = false
): Promise {
let cachedValues: string[] = await this.cacheService.match(
- `*.usageLevels.${userId}.${serviceName}.${usageLimit}`
+ `*.usagelevels.${userId}.${serviceName}.${usageLimit}`
);
cachedValues = cachedValues.sort((a, b) => {
const aTimestamp = parseInt(a.split('.')[0]);
@@ -467,22 +606,24 @@ class ContractService {
}
async _createUsageLevels(
- services: Record
+ services: Record,
+ organizationId: string
): Promise>> {
const usageLevels: Record> = {};
for (const serviceName in services) {
const pricing: LeanPricing = await this.serviceService.showPricing(
serviceName,
- services[serviceName]
+ services[serviceName],
+ organizationId
);
- const serviceUsageLevels: Record | undefined = generateUsageLevels(pricing);
+ const serviceUsageLevels: Record | undefined =
+ generateUsageLevels(pricing);
if (serviceUsageLevels) {
usageLevels[serviceName] = serviceUsageLevels;
}
-
}
return usageLevels;
}
@@ -497,7 +638,11 @@ class ContractService {
return serviceNames;
}
- async _resetUsageLimitUsageLevels(contract: LeanContract, usageLimit: string): Promise {
+ async _resetUsageLimitUsageLevels(
+ contract: LeanContract,
+ usageLimit: string,
+ organizationId: string
+ ): Promise {
const serviceNames: string[] = this._discoverUsageLimitServices(contract, usageLimit);
if (serviceNames.length === 0) {
@@ -510,7 +655,7 @@ class ContractService {
contract.usageLevels[serviceName][usageLimit].consumed = 0;
if (contract.usageLevels[serviceName][usageLimit].resetTimeStamp) {
- await this._setResetTimeStamp(contract, serviceName, usageLimit);
+ await this._setResetTimeStamp(contract, serviceName, usageLimit, organizationId);
}
}
}
@@ -518,13 +663,15 @@ class ContractService {
async _setResetTimeStamp(
contract: LeanContract,
serviceName: string,
- usageLimit: string
+ usageLimit: string,
+ organizationId: string
): Promise {
const pricingVersion = contract.contractedServices[serviceName];
const servicePricing: LeanPricing = await this.serviceService.showPricing(
serviceName,
- pricingVersion
+ pricingVersion,
+ organizationId
);
contract.usageLevels[serviceName][usageLimit].resetTimeStamp = addPeriodToDate(
@@ -533,7 +680,11 @@ class ContractService {
);
}
- async _resetUsageLevels(contract: LeanContract, renewableOnly: boolean): Promise {
+ async _resetUsageLevels(
+ contract: LeanContract,
+ renewableOnly: boolean,
+ organizationId: string
+ ): Promise {
for (const serviceName in contract.usageLevels) {
for (const usageLimit in contract.usageLevels[serviceName]) {
if (renewableOnly && !contract.usageLevels[serviceName][usageLimit].resetTimeStamp) {
@@ -541,29 +692,34 @@ class ContractService {
}
contract.usageLevels[serviceName][usageLimit].consumed = 0;
if (contract.usageLevels[serviceName][usageLimit].resetTimeStamp) {
- await this._setResetTimeStamp(contract, serviceName, usageLimit);
+ await this._setResetTimeStamp(contract, serviceName, usageLimit, organizationId);
}
}
}
}
- async _resetRenewableUsageLevels(contract: LeanContract, usageLimitsToRenew: string[]): Promise {
-
+ async _resetRenewableUsageLevels(
+ contract: LeanContract,
+ usageLimitsToRenew: string[],
+ organizationId: string
+ ): Promise {
if (usageLimitsToRenew.length === 0) {
return contract;
}
-
+
const contractToUpdate = { ...contract };
for (const usageLimitId of usageLimitsToRenew) {
const serviceName: string = usageLimitId.split('-')[0];
const usageLimitName: string = usageLimitId.split('-')[1];
- let currentResetTimeStamp = contractToUpdate.usageLevels[serviceName][usageLimitName].resetTimeStamp;
+ let currentResetTimeStamp =
+ contractToUpdate.usageLevels[serviceName][usageLimitName].resetTimeStamp;
if (currentResetTimeStamp && isAfter(new Date(), currentResetTimeStamp)) {
const pricing: LeanPricing = await this.serviceService.showPricing(
serviceName,
- contractToUpdate.contractedServices[serviceName]
+ contractToUpdate.contractedServices[serviceName],
+ organizationId
);
currentResetTimeStamp = addPeriodToDate(
currentResetTimeStamp,
@@ -573,8 +729,11 @@ class ContractService {
}
}
- const updatedContract = await this.contractRepository.update(contract.userContact.userId, contractToUpdate);
-
+ const updatedContract = await this.contractRepository.update(
+ contract.userContact.userId,
+ contractToUpdate
+ );
+
if (!updatedContract) {
throw new Error(`Failed to update contract for userId ${contract.userContact.userId}`);
}
diff --git a/api/src/main/services/FeatureEvaluationService.ts b/api/src/main/services/FeatureEvaluationService.ts
index fa841ed..c89c41d 100644
--- a/api/src/main/services/FeatureEvaluationService.ts
+++ b/api/src/main/services/FeatureEvaluationService.ts
@@ -45,7 +45,7 @@ class FeatureEvaluationService {
this.cacheService = container.resolve('cacheService');
}
- async index(queryParams: FeatureIndexQueryParams): Promise {
+ async index(queryParams: FeatureIndexQueryParams, organizationId: string): Promise {
const {
featureName,
serviceName,
@@ -59,7 +59,7 @@ class FeatureEvaluationService {
} = queryParams || {};
// Step 1: Generate an object that clasifies pricing details by version and service (i.e. Record>)
- const pricings = await this._getPricingsToReturn(show);
+ const pricings = await this._getPricingsToReturn(show, organizationId);
// Step 2: Parse pricings to a list of features
const features: LeanFeature[] = this._parsePricingsToFeatures(
@@ -80,6 +80,7 @@ class FeatureEvaluationService {
async eval(
userId: string,
+ reqOrg: any,
options: FeatureEvalQueryParams
): Promise<
| SimpleFeatureEvaluation
@@ -93,7 +94,7 @@ class FeatureEvaluationService {
// Step 1: Retrieve contexts
const { subscriptionContext, pricingContext, evaluationContext } =
- await this._retrieveContextsByUserId(userId, options.server);
+ await this._retrieveContextsByUserId(userId, options.server, reqOrg);
// Step 2: Perform the evaluation
const evaluationResults = await evaluateAllFeatures(
@@ -115,7 +116,7 @@ class FeatureEvaluationService {
: evaluationResults;
}
- async generatePricingToken(userId: string, options: { server: boolean }): Promise {
+ async generatePricingToken(userId: string, reqOrg: any, options: { server: boolean }): Promise {
const cachedToken = await this.cacheService.get(`features.${userId}.pricingToken`);
if (cachedToken) {
@@ -128,7 +129,7 @@ class FeatureEvaluationService {
throw new Error(`Contract with userId ${userId} not found`);
}
- const result = (await this.eval(userId, {
+ const result = (await this.eval(userId, reqOrg, {
details: true,
server: options.server,
returnContexts: true,
@@ -154,6 +155,7 @@ class FeatureEvaluationService {
userId: string,
featureId: string,
expectedConsumption: Record,
+ reqOrg: any,
options: SingleFeatureEvalQueryParams
): Promise {
let evaluation = await this.cacheService.get(`features.${userId}.eval.${featureId}`);
@@ -166,7 +168,7 @@ class FeatureEvaluationService {
// Step 1: Retrieve contexts
const { subscriptionContext, pricingContext, evaluationContext } =
- await this._retrieveContextsByUserId(userId, options.server);
+ await this._retrieveContextsByUserId(userId, options.server, reqOrg);
if (options.revert) {
await this.contractService._revertExpectedConsumption(userId, featureId, options.latest);
@@ -193,14 +195,19 @@ class FeatureEvaluationService {
}
}
- async _getPricingsByContract(contract: LeanContract): Promise> {
+ async _getPricingsByContract(contract: LeanContract, reqOrg: any): Promise> {
const pricingsToReturn: Record = {};
// Parallelize pricing retrieval per service (showPricing may fetch remote URLs)
const serviceNames = Object.keys(contract.contractedServices);
const pricingPromises = serviceNames.map(async (serviceName) => {
const pricingVersion = escapeVersion(contract.contractedServices[serviceName]);
- const pricing = await this.serviceService.showPricing(serviceName, pricingVersion);
+ const pricing = await this.serviceService.showPricing(serviceName, pricingVersion, reqOrg.id);
+ if (!pricing) {
+ throw new Error(
+ `Pricing version ${pricingVersion} for service ${serviceName} not found in organization ${reqOrg.name}`
+ );
+ }
return { serviceName, pricing };
});
@@ -291,12 +298,13 @@ class FeatureEvaluationService {
}
async _getPricingsToReturn(
- show: 'active' | 'archived' | 'all'
+ show: 'active' | 'archived' | 'all',
+ organizationId: string
): Promise>> {
const pricingsToReturn: Record> = {};
// Step 1: Return all services (only fields required to build pricings map)
- const services = await this.serviceRepository.findAllNoQueries(false, { name: 1, activePricings: 1, archivedPricings: 1 });
+ const services = await this.serviceRepository.findAllNoQueries(organizationId, false, { name: 1, activePricings: 1, archivedPricings: 1 });
if (!services) {
return {};
@@ -311,35 +319,32 @@ class FeatureEvaluationService {
let pricingsWithUrlToCheck: string[] = [];
if (show === 'active' || show === 'all') {
- pricingsWithIdToCheck = pricingsWithIdToCheck.concat(
- Object.entries(service.activePricings)
- .filter(([_, pricing]) => pricing.id)
- .map(([version, _]) => version)
- );
- pricingsWithUrlToCheck = pricingsWithUrlToCheck.concat(
- Object.entries(service.activePricings)
- .filter(([_, pricing]) => pricing.url)
- .map(([version, _]) => version)
- );
+ for (const [version, pricing] of service.activePricings) {
+ if (pricing.id) {
+ pricingsWithIdToCheck.push(version);
+ }
+ if (pricing.url) {
+ pricingsWithUrlToCheck.push(version);
+ }
+ }
}
if ((show === 'archived' || show === 'all') && service.archivedPricings) {
- pricingsWithIdToCheck = pricingsWithIdToCheck.concat(
- Object.entries(service.archivedPricings)
- .filter(([_, pricing]) => pricing.id)
- .map(([version, _]) => version)
- );
- pricingsWithUrlToCheck = pricingsWithUrlToCheck.concat(
- Object.entries(service.archivedPricings)
- .filter(([_, pricing]) => pricing.url)
- .map(([version, _]) => version)
- );
+ for (const [version, pricing] of service.archivedPricings) {
+ if (pricing.id) {
+ pricingsWithIdToCheck.push(version);
+ }
+ if (pricing.url) {
+ pricingsWithUrlToCheck.push(version);
+ }
+ }
}
// Step 3: For each group (id and url) parse the versions to actual ExpectedPricingType objects
let pricingsWithId = await this.serviceRepository.findPricingsByServiceName(
serviceName,
- pricingsWithIdToCheck
+ pricingsWithIdToCheck,
+ organizationId
);
pricingsWithId ??= [];
@@ -350,7 +355,7 @@ class FeatureEvaluationService {
// Fetch all remote pricings for this service in parallel with limited concurrency
const urlVersions = pricingsWithUrlToCheck.map((version) => ({
version,
- url: (service.activePricings[version] ?? service.archivedPricings[version]).url,
+ url: (service.activePricings.get(version) ?? service.archivedPricings!.get(version))!.url,
}));
const concurrency = 8;
@@ -385,7 +390,8 @@ class FeatureEvaluationService {
async _retrieveContextsByUserId(
userId: string,
- server: boolean = false
+ server: boolean = false,
+ reqOrg: any
): Promise<{
subscriptionContext: SubscriptionContext;
pricingContext: PricingContext;
@@ -431,7 +437,8 @@ class FeatureEvaluationService {
if (usageLevelsToRenew.length > 0) {
contract = await this.contractService._resetRenewableUsageLevels(
contract,
- usageLevelsToRenew
+ usageLevelsToRenew,
+ reqOrg.id
);
}
@@ -441,7 +448,7 @@ class FeatureEvaluationService {
);
// Step 2.1: Retrieve all pricings to which the user is subscribed
- const userPricings = await this._getPricingsByContract(contract);
+ const userPricings = await this._getPricingsByContract(contract, reqOrg);
// Step 2.2: Get User Subscriptions
const userSubscriptionByService: Record<
diff --git a/api/src/main/services/OrganizationService.ts b/api/src/main/services/OrganizationService.ts
new file mode 100644
index 0000000..b782966
--- /dev/null
+++ b/api/src/main/services/OrganizationService.ts
@@ -0,0 +1,577 @@
+import container from '../config/container';
+import { OrganizationApiKeyRole } from '../types/permissions';
+import OrganizationRepository from '../repositories/mongoose/OrganizationRepository';
+import {
+ LeanApiKey,
+ LeanOrganization,
+ OrganizationFilter,
+ OrganizationMember,
+} from '../types/models/Organization';
+import { generateOrganizationApiKey } from '../utils/users/helpers';
+import UserRepository from '../repositories/mongoose/UserRepository';
+import { validateOrganizationData } from './validation/OrganizationServiceValidations';
+import ServiceService from './ServiceService';
+
+class OrganizationService {
+ private organizationRepository: OrganizationRepository;
+ private userRepository: UserRepository;
+ private serviceService: ServiceService;
+
+ constructor() {
+ this.organizationRepository = container.resolve('organizationRepository');
+ this.userRepository = container.resolve('userRepository');
+ this.serviceService = container.resolve('serviceService');
+ }
+
+ async find(filters: OrganizationFilter, limit?: number, offset?: number): Promise {
+ const organizations = await this.organizationRepository.find(filters, limit, offset);
+ return organizations;
+ }
+
+ async count(filters: OrganizationFilter): Promise {
+ return await this.organizationRepository.count(filters);
+ }
+
+ async findById(organizationId: string): Promise {
+ const organization = await this.organizationRepository.findById(organizationId);
+ return organization;
+ }
+
+ async findByOwner(owner: string): Promise {
+ const organization = await this.organizationRepository.findByOwner(owner);
+ return organization;
+ }
+
+ async findByUser(username: string): Promise {
+ const organizations = await this.organizationRepository.findByUser(username);
+ return organizations;
+ }
+
+ async findByApiKey(
+ apiKey: string
+ ): Promise<{ organization: LeanOrganization; apiKeyData: LeanApiKey }> {
+ const organization = await this.organizationRepository.findByApiKey(apiKey);
+
+ if (!organization) {
+ throw new Error('Invalid API Key');
+ }
+
+ // Find the specific API key data
+ const apiKeyData = organization.apiKeys.find(key => key.key === apiKey);
+
+ if (!apiKeyData) {
+ throw new Error('Invalid API Key');
+ }
+
+ return {
+ organization,
+ apiKeyData,
+ };
+ }
+
+ async create(organizationData: any, reqUser: any): Promise {
+ validateOrganizationData(organizationData);
+ const proposedOwner = await this.userRepository.findByUsername(organizationData.owner);
+
+ if (!proposedOwner) {
+ throw new Error(`INVALID DATA: User with username ${organizationData.owner} does not exist.`);
+ }
+
+ if (proposedOwner.username !== reqUser.username && reqUser.role !== 'ADMIN') {
+ throw new Error('Only admins can create organizations for other users.');
+ }
+
+ if (organizationData.default) {
+ const proposedOwnerDefaultOrg = await this.organizationRepository.find({ owner: proposedOwner.username, default: true });
+
+ if (proposedOwnerDefaultOrg.length > 0) {
+ throw new Error(`CONFLICT: The proposed owner ${proposedOwner.username} already has a default organization.`);
+ }
+ }
+
+ const organizationPayload: any = {
+ name: organizationData.name,
+ owner: organizationData.owner,
+ apiKeys: [
+ {
+ key: generateOrganizationApiKey(),
+ scope: 'ALL',
+ },
+ ],
+ members: [],
+ default: organizationData.default || false,
+ };
+
+ const organization = await this.organizationRepository.create(organizationPayload);
+ return organization;
+ }
+
+ async addApiKey(
+ organizationId: string,
+ scope: OrganizationApiKeyRole,
+ reqUser: any
+ ): Promise {
+ // 1. Basic Input Validation
+ const validScopes = ['ALL', 'MANAGEMENT', 'EVALUATION'];
+ if (!scope || !validScopes.includes(scope)) {
+ throw new Error(`INVALID DATA: scope must be one of ${validScopes.join(', ')}.`);
+ }
+
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`Organization with ID ${organizationId} does not exist.`);
+ }
+
+ // 2. Identify roles and context once (O(n) search)
+ const isSpaceAdmin = reqUser.role === 'ADMIN';
+ const isOwner = organization.owner === reqUser.username;
+
+ // Find the requester within the organization members
+ const reqMember = organization.members.find(m => m.username === reqUser.username);
+ const reqMemberRole = reqMember?.role;
+
+ // Define privilege tiers
+ const hasManagerPrivileges = ['OWNER', 'ADMIN', 'MANAGER'].includes(reqMemberRole || '');
+ const hasHighPrivileges = ['OWNER', 'ADMIN'].includes(reqMemberRole || '');
+
+ // --- PERMISSION CHECKS ---
+
+ // Rule 1: General permission to add API keys
+ // Requires Space Admin, Org Owner, or Org Manager+
+ if (!isSpaceAdmin && !isOwner && !hasManagerPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER, ADMIN and MANAGER can add API keys.'
+ );
+ }
+
+ // Rule 2: Protection for 'ALL' scope keys
+ // 'ALL' scope keys are powerful; only Space Admins or Org Owner/Admins can create them.
+ if (scope === 'ALL' && !isSpaceAdmin && !isOwner && !hasHighPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER and ADMIN can add API keys with ALL scope.'
+ );
+ }
+
+ // 3. Data Generation & Persistence
+ // We generate the key only after all permissions are verified
+ const apiKeyData: LeanApiKey = {
+ key: generateOrganizationApiKey(),
+ scope: scope,
+ };
+
+ await this.organizationRepository.addApiKey(organizationId, apiKeyData);
+ }
+
+ async addMember(
+ organizationId: string,
+ organizationMember: OrganizationMember,
+ reqUser: any
+ ): Promise {
+ // 1. Basic validation
+ if (!organizationMember.username || !organizationMember.role) {
+ throw new Error('INVALID DATA: organizationMember must have username and role.');
+ }
+
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`Organization with ID ${organizationId} does not exist.`);
+ }
+
+ // 2. Identify roles and context once
+ const isSpaceAdmin = reqUser.role === 'ADMIN';
+ const isOwner = organization.owner === reqUser.username;
+
+ // Locate the requester within the organization's member list
+ const reqMember = organization.members.find(m => m.username === reqUser.username);
+ const reqMemberRole = reqMember?.role;
+
+ // Define privilege tiers
+ const hasManagerPrivileges = ['OWNER', 'ADMIN', 'MANAGER'].includes(reqMemberRole || '');
+ const hasHighPrivileges = ['OWNER', 'ADMIN'].includes(reqMemberRole || '');
+
+ // --- PERMISSION CHECKS ---
+
+ // Rule 1: General permission to add members
+ // Requires Space Admin, Org Owner, or Org Manager+
+ if (!isSpaceAdmin && !isOwner && !hasManagerPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER, ADMIN and MANAGER can add members.'
+ );
+ }
+
+ // Rule 2: Escalated permission for adding High-Level roles
+ // Only Space Admins or Org Owner/Admins can grant OWNER or ADMIN roles
+ const targetIsHighLevel = ['OWNER', 'ADMIN'].includes(organizationMember.role);
+ if (targetIsHighLevel && !isSpaceAdmin && !isOwner && !hasHighPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER/ADMIN can add high-level members.'
+ );
+ }
+
+ // 3. External dependency check (User existence)
+ const userToAssign = await this.userRepository.findByUsername(organizationMember.username);
+
+ if (!userToAssign) {
+ throw new Error(`INVALID DATA: User with username ${organizationMember.username} does not exist.`);
+ }
+
+ // 4. Persistence
+ await this.organizationRepository.addMember(organizationId, organizationMember);
+ }
+
+ async updateMemberRole(
+ organizationId: string,
+ username: string,
+ role: string,
+ reqUser: any
+ ): Promise {
+ // 1. Basic validation
+ if (!username || !role) {
+ throw new Error('INVALID DATA: username and role are required.');
+
+ }
+
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`INVALID DATA: Organization with ID ${organizationId} does not exist.`);
+ }
+
+ if (!organization.members.some(member => member.username === username)) {
+ throw new Error(`INVALID DATA: User with username ${username} is not a member of the organization.`);
+ }
+
+ // 2. Identify roles and context once
+ const isSpaceAdmin = reqUser.role === 'ADMIN';
+
+ // Locate the requester within the organization's member list
+ const reqMemberRole = reqUser.orgRole;
+ const isOwner = reqMemberRole === 'OWNER';
+
+ // Locate user being updated within the organization's member list
+ const userToUpdate = organization.members.find(m => m.username === username);
+
+ if (!userToUpdate){
+ throw new Error(`INVALID DATA: User with username ${username} is not a member of the organization.`);
+ }
+
+ if (userToUpdate.role === role) {
+ throw new Error(`CONFLICT: User with username ${username} already has the role ${role}.`);
+ }
+
+ // Define privilege tiers
+ const hasManagerPrivileges = ['OWNER', 'ADMIN', 'MANAGER'].includes(reqMemberRole || '');
+ const hasHighPrivileges = ['OWNER', 'ADMIN'].includes(reqMemberRole || '');
+
+ // --- PERMISSION CHECKS ---
+
+ // Rule 1: General permission to add members
+ // Requires Space Admin, Org Owner, or Org Manager+
+ if (!isSpaceAdmin && !isOwner && !hasManagerPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER, ADMIN and MANAGER can update member roles.'
+ );
+ }
+
+ // Rule 2: Escalated permission for adding High-Level roles
+ // Only Space Admins or Org Owner/Admins can grant OWNER or ADMIN roles
+ const targetIsHighLevel = ['OWNER', 'ADMIN'].includes(userToUpdate?.role || '');
+ if (targetIsHighLevel && !isSpaceAdmin && !isOwner && !hasHighPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER/ADMIN can add high-level members.'
+ );
+ }
+
+ const newRoleIsHighLevel = ['OWNER', 'ADMIN'].includes(role);
+ if (newRoleIsHighLevel && !isSpaceAdmin && !isOwner && !hasHighPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER/ADMIN can add high-level members.'
+ );
+ }
+
+ // 4. Persistence
+ await this.organizationRepository.updateMemberRole(organizationId, username, role);
+ }
+
+ async update(organizationId: string, updateData: any, reqUser: any): Promise {
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`INVALID DATA: Organization with ID ${organizationId} does not exist.`);
+ }
+
+ if (
+ organization.owner !== reqUser.username &&
+ reqUser.role !== 'ADMIN' &&
+ !organization.members
+ .filter(m => m.username && ['OWNER', 'ADMIN', 'MANAGER'].includes(m.role))
+ .map(m => m.username)
+ .includes(reqUser.username)
+ ) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER, ADMIN and MANAGER can update organizations.'
+ );
+ }
+
+ if (updateData.name) {
+ if (typeof updateData.name !== 'string') {
+ throw new Error('INVALID DATA: Invalid organization name.');
+ }
+
+ organization.name = updateData.name;
+ }
+
+ if (updateData.owner) {
+ if (reqUser.role !== 'ADMIN' && organization.owner !== reqUser.username) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization owners can change organization ownership.'
+ );
+ }
+
+ if (organization.default) {
+ throw new Error('CONFLICT: Cannot transfer ownership of a default organization.');
+ }
+
+ const proposedOwner = await this.userRepository.findByUsername(updateData.owner);
+ if (!proposedOwner) {
+ throw new Error(`INVALID DATA: User with username ${updateData.owner} does not exist.`);
+ }
+
+ // Save the old owner to add them as ADMIN member
+ const oldOwner = organization.owner;
+ const newOwner = updateData.owner;
+
+ // Check if new owner is currently a member - if so, remove them from members
+ const newOwnerIsMember = organization.members.some(m => m.username === newOwner);
+ if (newOwnerIsMember) {
+ await this.organizationRepository.removeMember(organizationId, newOwner);
+ }
+
+ // Check if old owner is already a member
+ const oldOwnerIsMember = organization.members.some(m => m.username === oldOwner);
+
+ // Add old owner as ADMIN member if not already a member
+ if (!oldOwnerIsMember) {
+ await this.organizationRepository.addMember(organizationId, {
+ username: oldOwner,
+ role: 'ADMIN',
+ });
+ }
+
+ organization.owner = newOwner;
+ }
+
+ if (updateData.default !== undefined) {
+ if (typeof updateData.default !== 'boolean') {
+ throw new Error('INVALID DATA: Invalid organization default flag.');
+ }
+
+ const proposedOwnerDefaultOrg = await this.organizationRepository.find({ owner: organization.owner, default: true });
+
+ if (proposedOwnerDefaultOrg.length > 0) {
+ throw new Error(`CONFLICT: The proposed owner ${organization.owner} already has a default organization.`);
+ }
+
+ organization.default = updateData.default;
+ }
+
+ await this.organizationRepository.update(organizationId, updateData);
+ }
+
+ async updateUsername(oldUsername: string, newUsername: string): Promise {
+ const organizations = await this.organizationRepository.findByUser(oldUsername);
+ for (const org of organizations) {
+ if (org.owner === oldUsername) {
+ await this.organizationRepository.update(org.id!, { owner: newUsername });
+ }else{
+ await this.organizationRepository.update(org.id!, { members: org.members.map(m => m.username === oldUsername ? { ...m, username: newUsername } : m) });
+ }
+ }
+ }
+
+ async removeApiKey(organizationId: string, apiKey: string, reqUser: any): Promise {
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`Organization with ID ${organizationId} does not exist.`);
+ }
+
+ // 1. Identify the specific API key to be removed
+ const targetKey = organization.apiKeys.find(k => k.key === apiKey);
+ if (!targetKey) {
+ throw new Error(`API Key not found in organization ${organizationId}.`);
+ }
+
+ // 2. Identify roles and context (O(n) search)
+ const isSpaceAdmin = reqUser.role === 'ADMIN';
+ const isOwner = organization.owner === reqUser.username;
+
+ const reqMember = organization.members.find(m => m.username === reqUser.username);
+ const reqMemberRole = reqMember?.role;
+
+ // Define privilege tiers
+ const hasManagerPrivileges = ['OWNER', 'ADMIN', 'MANAGER'].includes(reqMemberRole || '');
+ const hasHighPrivileges = ['OWNER', 'ADMIN'].includes(reqMemberRole || '');
+
+ // --- PERMISSION CHECKS ---
+
+ // Rule 1: General removal permission
+ // At minimum, you must be an Org Manager, Org Owner, or Space Admin to remove any key.
+ if (!isSpaceAdmin && !isOwner && !hasManagerPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER, ADMIN and MANAGER can remove API keys.'
+ );
+ }
+
+ // Rule 2: Protection for 'ALL' scope keys
+ // If the key has 'ALL' scope, Managers are NOT allowed to remove it.
+ if (targetKey.scope === 'ALL' && !isSpaceAdmin && !isOwner && !hasHighPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER and ADMIN can remove API keys with ALL scope.'
+ );
+ }
+
+ // 3. Execution
+ await this.organizationRepository.removeApiKey(organizationId, apiKey);
+ }
+
+ async removeMember(organizationId: string, username: string, reqUser: any): Promise {
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`Organization with ID ${organizationId} does not exist.`);
+ }
+
+ // 1. Identify key roles and context once (O(n) complexity instead of multiple loops)
+ const isSpaceAdmin = reqUser.role === 'ADMIN';
+ const isOwner = organization.owner === reqUser.username;
+
+ // Find the requester and the target member within the organization's member list
+ const reqMember = organization.members.find(m => m.username === reqUser.username);
+ const targetMember = organization.members.find(m => m.username === username);
+
+ const reqMemberRole = reqMember?.role;
+ const targetMemberRole = targetMember?.role;
+
+ // 2. Define permission flags based on hierarchy
+ // High-level staff (Owner, Admin)
+ const hasHighPrivileges = ['OWNER', 'ADMIN'].includes(reqMemberRole || '');
+
+ // --- VALIDATION RULES ---
+
+ // Rule 1: Protection for ADMIN members
+ // Admin members are protected; they can only be removed by Space Admins, the Owner, or other Org Admins.
+ if (targetMemberRole === 'ADMIN') {
+ if (!isSpaceAdmin && !isOwner && !hasHighPrivileges) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization-level OWNER/ADMIN can remove ADMIN members.'
+ );
+ }
+ }
+
+ // Rule 2: Evaluator restrictions
+ // Evaluators do not have management permissions; they can only opt-out (remove themselves).
+ if (reqMemberRole === 'EVALUATOR' && username !== reqUser.username) {
+ throw new Error('PERMISSION ERROR: Organization EVALUATOR can only remove themselves.');
+ }
+
+ // 3. Execute the atomic removal operation in the database
+ await this.organizationRepository.removeMember(organizationId, username);
+ }
+
+ async destroy(organizationId: string, reqUser: any): Promise {
+ const organization = await this.organizationRepository.findById(organizationId);
+
+ if (!organization) {
+ throw new Error(`INVALID DATA: Organization with ID ${organizationId} does not exist.`);
+ }
+
+ if (organization.default) {
+ throw new Error('CONFLICT: The default organization for a user cannot be deleted.');
+ }
+
+ if (
+ organization.owner !== reqUser.username &&
+ reqUser.role !== 'ADMIN'
+ ) {
+ throw new Error(
+ 'PERMISSION ERROR: Only SPACE admins or organization owners can delete organizations.'
+ );
+ }
+
+ await this.serviceService.prune(organizationId);
+ await this.organizationRepository.delete(organizationId);
+ }
+
+ /**
+ * Force delete an organization (bypass default protection). Used when owner is being deleted.
+ */
+ async forceDelete(organizationId: string): Promise {
+ await this.serviceService.prune(organizationId);
+ await this.organizationRepository.delete(organizationId);
+ }
+
+ /**
+ * Remove a user from all organizations.
+ * - Removes user from members lists
+ * - For organizations owned by the user: transfer ownership to next ADMIN, MANAGER, EVALUATOR (in that order)
+ * or delete the organization if no candidates. When `allowDeleteDefault` is true, default orgs can be deleted.
+ */
+ async removeUserFromOrganizations(username: string, options?: { allowDeleteDefault?: boolean, actingUser?: any }): Promise {
+ const allowDeleteDefault = options?.allowDeleteDefault || false;
+
+ // Get organizations where the user is owner or member
+ const allOrgs = await this.organizationRepository.findByUser(username);
+
+ for (const org of allOrgs) {
+ const orgId = (org as any).id as string | undefined;
+
+ // If user is a member, remove them
+ const isMember = (org.members || []).some(m => m.username === username);
+ if (isMember && orgId) {
+ try {
+ await this.organizationRepository.removeMember(orgId, username);
+ } catch (err) {
+ // ignore if not present or race
+ }
+ }
+
+ // If user is owner, handle transfer or deletion
+ if (org.owner === username) {
+ const members = org.members || [];
+ // Candidates exclude owner
+ const candidates = members.filter((m: any) => m.username !== username);
+
+ let newOwner: string | undefined;
+ if (candidates.length > 0) {
+ const adminCandidate = candidates.find((m: any) => m.role === 'ADMIN');
+ const managerCandidate = candidates.find((m: any) => m.role === 'MANAGER');
+ const evaluatorCandidate = candidates.find((m: any) => m.role === 'EVALUATOR');
+
+ newOwner = (adminCandidate || managerCandidate || evaluatorCandidate)?.username;
+ }
+
+ if (newOwner && orgId) {
+ // change owner and ensure the old owner is removed from members
+ await this.organizationRepository.changeOwner(orgId, newOwner);
+ try {
+ await this.organizationRepository.removeMember(orgId, username);
+ } catch (err) {
+ // ignore
+ }
+ } else if (orgId) {
+ // No candidates: delete org. Allow deletion of default when explicitly permitted.
+ if (org.default && !allowDeleteDefault) {
+ // If default deletion is not allowed, skip transfer/delete — leave org owned by deleted user (edge case)
+ continue;
+ }
+ await this.forceDelete(orgId);
+ }
+ }
+ }
+ }
+}
+
+export default OrganizationService;
diff --git a/api/src/main/services/ServiceService.ts b/api/src/main/services/ServiceService.ts
index c448885..1538bba 100644
--- a/api/src/main/services/ServiceService.ts
+++ b/api/src/main/services/ServiceService.ts
@@ -19,11 +19,13 @@ import { generateUsageLevels } from '../utils/contracts/helpers';
import { escapeVersion } from '../utils/helpers';
import { resetEscapeVersionInService } from '../utils/services/helpers';
import CacheService from './CacheService';
+import OrganizationRepository from '../repositories/mongoose/OrganizationRepository';
class ServiceService {
private readonly serviceRepository: ServiceRepository;
private readonly pricingRepository: PricingRepository;
private readonly contractRepository: ContractRepository;
+ private readonly organizationRepository: OrganizationRepository;
private readonly cacheService: CacheService;
private readonly eventService;
@@ -31,12 +33,13 @@ class ServiceService {
this.serviceRepository = container.resolve('serviceRepository');
this.pricingRepository = container.resolve('pricingRepository');
this.contractRepository = container.resolve('contractRepository');
+ this.organizationRepository = container.resolve('organizationRepository');
this.eventService = container.resolve('eventService');
this.cacheService = container.resolve('cacheService');
}
- async index(queryParams: ServiceQueryFilters) {
- const services = await this.serviceRepository.findAll(queryParams);
+ async index(queryParams: ServiceQueryFilters, organizationId?: string) {
+ const services = await this.serviceRepository.findAll(organizationId, queryParams);
for (const service of services) {
resetEscapeVersionInService(service);
@@ -45,25 +48,26 @@ class ServiceService {
return services;
}
- async indexByNames(serviceNames: string[]) {
+ async indexByNames(serviceNames: string[], organizationId: string) {
if (!Array.isArray(serviceNames) || serviceNames.length === 0) {
throw new Error('Invalid request: serviceNames must be a non-empty array');
}
- const services = await this.serviceRepository.findByNames(serviceNames);
+ const services = await this.serviceRepository.findByNames(serviceNames, organizationId);
return services;
}
- async indexPricings(serviceName: string, pricingStatus: string) {
- let service = await this.cacheService.get(`service.${serviceName}`);
+ async indexPricings(serviceName: string, pricingStatus: string, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
if (!service) {
- service = await this.serviceRepository.findByName(serviceName);
- await this.cacheService.set(`service.${serviceName}`, service, 3600, true);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
+ await this.cacheService.set(cacheKey, service, 3600, true);
}
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
const pricingsToReturn =
@@ -73,19 +77,20 @@ class ServiceService {
return [];
}
- const versionsToRetrieve = Object.keys(pricingsToReturn);
+ const versionsToRetrieve = Array.from(pricingsToReturn.keys()) as string[];
- const versionsToRetrieveLocally = versionsToRetrieve.filter(
- version => pricingsToReturn[version]?.id
+ const versionsToRetrieveLocally: string[] = versionsToRetrieve.filter(
+ version => pricingsToReturn.get(version)?.id
);
- const versionsToRetrieveRemotely = versionsToRetrieve.filter(
- version => !pricingsToReturn[version]?.id
+ const versionsToRetrieveRemotely: string[] = versionsToRetrieve.filter(
+ version => !pricingsToReturn.get(version)?.id
);
const locallySavedPricings =
(await this.serviceRepository.findPricingsByServiceName(
service.name,
- versionsToRetrieveLocally
+ versionsToRetrieveLocally,
+ organizationId
)) ?? [];
const remotePricings = [];
@@ -95,8 +100,8 @@ class ServiceService {
for (let i = 0; i < versionsToRetrieveRemotely.length; i += concurrency) {
const batch = versionsToRetrieveRemotely.slice(i, i + concurrency);
const batchResults = await Promise.all(
- batch.map(async (version) => {
- const url = pricingsToReturn[version].url;
+ batch.map(async version => {
+ const url = pricingsToReturn.get(version)?.url;
// Try cache first
let pricing = await this.cacheService.get(`pricing.url.${url}`);
if (!pricing) {
@@ -115,17 +120,18 @@ class ServiceService {
remotePricings.push(...batchResults);
}
- return (locallySavedPricings as unknown as ExpectedPricingType[]).concat(remotePricings);
+ return (locallySavedPricings as unknown as LeanPricing[]).concat(remotePricings);
}
- async show(serviceName: string) {
- let service = await this.cacheService.get(`service.${serviceName}`);
+ async show(serviceName: string, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
if (!service) {
- service = await this.serviceRepository.findByName(serviceName);
- await this.cacheService.set(`service.${serviceName}`, service, 3600, true);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
+ await this.cacheService.set(cacheKey, service, 3600, true);
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
}
@@ -134,22 +140,22 @@ class ServiceService {
return service;
}
- async showPricing(serviceName: string, pricingVersion: string) {
+ async showPricing(serviceName: string, pricingVersion: string, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
- let service = await this.cacheService.get(`service.${serviceName}`);
-
- if (!service){
- service = await this.serviceRepository.findByName(serviceName);
+ if (!service) {
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
}
const formattedPricingVersion = escapeVersion(pricingVersion);
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
const pricingLocator =
- service.activePricings[formattedPricingVersion] ||
- service.archivedPricings[formattedPricingVersion];
+ service.activePricings.get(formattedPricingVersion) ??
+ service.archivedPricings?.get(formattedPricingVersion);
if (!pricingLocator) {
throw new Error(`Pricing version ${pricingVersion} not found for service ${serviceName}`);
@@ -162,17 +168,15 @@ class ServiceService {
}
if (pricingLocator.id) {
-
let pricing = await this.cacheService.get(`pricing.id.${pricingLocator.id}`);
- if (!pricing){
+ if (!pricing) {
pricing = await this.pricingRepository.findById(pricingLocator.id);
await this.cacheService.set(`pricing.id.${pricingLocator.id}`, pricing, 3600, true);
}
return pricing;
} else {
-
let pricing = await this.cacheService.get(`pricing.url.${pricingLocator.url}`);
if (!pricing) {
@@ -184,15 +188,14 @@ class ServiceService {
}
}
- async create(receivedPricing: any, pricingType: 'file' | 'url') {
+ async create(receivedPricing: any, pricingType: 'file' | 'url', organizationId: string) {
try {
-
- await this.cacheService.del("features.*");
+ await this.cacheService.del('features.*');
if (pricingType === 'file') {
- return await this._createFromFile(receivedPricing);
+ return await this._createFromFile(receivedPricing, organizationId, undefined);
} else {
- return await this._createFromUrl(receivedPricing);
+ return await this._createFromUrl(receivedPricing, organizationId, undefined);
}
} catch (err) {
throw new Error((err as Error).message);
@@ -202,23 +205,25 @@ class ServiceService {
async addPricingToService(
serviceName: string,
receivedPricing: any,
- pricingType: 'file' | 'url'
+ pricingType: 'file' | 'url',
+ organizationId: string
) {
try {
- await this.cacheService.del("features.*");
- await this.cacheService.del(`service.${serviceName}`);
-
+ await this.cacheService.del('features.*');
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ await this.cacheService.del(cacheKey);
+
if (pricingType === 'file') {
- return await this._createFromFile(receivedPricing, serviceName);
+ return await this._createFromFile(receivedPricing, organizationId, serviceName);
} else {
- return await this._createFromUrl(receivedPricing, serviceName);
+ return await this._createFromUrl(receivedPricing, organizationId, serviceName);
}
} catch (err) {
throw new Error((err as Error).message);
}
}
- async _createFromFile(pricingFile: any, serviceName?: string) {
+ async _createFromFile(pricingFile: any, organizationId: string, serviceName?: string) {
let service: LeanService | null = null;
// Step 1: Parse and validate pricing
@@ -232,14 +237,14 @@ class ServiceService {
`Invalid request: The service name in the pricing file (${uploadedPricing.saasName}) does not match the service name in the URL (${serviceName})`
);
}
- service = await this.serviceRepository.findByName(serviceName);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
if (
- (service.activePricings && service.activePricings[formattedPricingVersion]) ||
- (service.archivedPricings && service.archivedPricings[formattedPricingVersion])
+ (service.activePricings && service.activePricings.get(formattedPricingVersion)) ||
+ (service.archivedPricings && service.archivedPricings.get(formattedPricingVersion))
) {
throw new Error(
`Pricing version ${uploadedPricing.version} already exists for service ${serviceName}`
@@ -247,8 +252,9 @@ class ServiceService {
}
}
- const pricingData: ExpectedPricingType & { _serviceName: string } = {
+ const pricingData: ExpectedPricingType & { _serviceName: string; _organizationId: string } = {
_serviceName: uploadedPricing.saasName,
+ _organizationId: organizationId,
...parsePricingToSpacePricingObject(uploadedPricing),
};
@@ -257,7 +263,7 @@ class ServiceService {
if (validationErrors.length > 0) {
throw new Error(`Validation errors: ${validationErrors.join(', ')}`);
}
-
+
// Step 2:
// - If the service does not exist (enabled), creates it
// - If an enabled service exists, updates it with the new pricing
@@ -266,8 +272,16 @@ class ServiceService {
// entries into archivedPricings (renaming collisions by appending timestamp)
if (!service) {
// Check if an enabled service exists
- const existingEnabled = await this.serviceRepository.findByName(uploadedPricing.saasName, false);
- const existingDisabled = await this.serviceRepository.findByName(uploadedPricing.saasName, true);
+ const existingEnabled = await this.serviceRepository.findByName(
+ uploadedPricing.saasName,
+ organizationId,
+ false
+ );
+ const existingDisabled = await this.serviceRepository.findByName(
+ uploadedPricing.saasName,
+ organizationId,
+ true
+ );
if (existingEnabled) {
throw new Error(`Invalid request: Service ${uploadedPricing.saasName} already exists`);
@@ -275,7 +289,7 @@ class ServiceService {
// Step 3: Create the service as it does not exist and add the pricing
const savedPricing = await this.pricingRepository.create(pricingData);
-
+
if (!savedPricing) {
throw new Error(`Pricing ${uploadedPricing.version} not saved`);
}
@@ -296,14 +310,14 @@ class ServiceService {
for (const key of Object.keys(existingDisabled.activePricings)) {
if (key === formattedPricingVersion) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = existingDisabled.activePricings[key];
+ newArchived.set(newKey, existingDisabled.activePricings.get(key)!);
} else {
// if archived already has this key, append timestamp
- if (newArchived[key]) {
+ if (newArchived.has(key)) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = existingDisabled.activePricings[key];
+ newArchived.set(newKey, existingDisabled.activePricings.get(key)!);
} else {
- newArchived[key] = existingDisabled.activePricings[key];
+ newArchived.set(key, existingDisabled.activePricings.get(key)!);
}
}
}
@@ -311,15 +325,22 @@ class ServiceService {
const updateData: any = {
disabled: false,
- activePricings: {
- [formattedPricingVersion]: {
- id: savedPricing.id,
- },
- },
+ activePricings: new Map([
+ [
+ formattedPricingVersion,
+ {
+ id: savedPricing.id,
+ },
+ ],
+ ]),
archivedPricings: newArchived,
};
- const updated = await this.serviceRepository.update(existingDisabled.name, updateData);
+ const updated = await this.serviceRepository.update(
+ existingDisabled.name,
+ updateData,
+ organizationId
+ );
if (!updated) {
throw new Error(`Service ${uploadedPricing.saasName} not updated`);
}
@@ -328,41 +349,50 @@ class ServiceService {
} else {
const serviceData = {
name: uploadedPricing.saasName,
- activePricings: {
- [formattedPricingVersion]: {
- id: savedPricing.id,
- },
- },
+ disabled: false,
+ organizationId: organizationId,
+ activePricings: new Map([
+ [
+ formattedPricingVersion,
+ {
+ id: savedPricing.id,
+ },
+ ],
+ ]),
};
try {
service = await this.serviceRepository.create(serviceData);
} catch (err) {
- throw new Error(`Service ${uploadedPricing.saasName} not saved: ${(err as Error).message}`);
+ throw new Error(
+ `Service ${uploadedPricing.saasName} not saved: ${(err as Error).message}`
+ );
}
}
} else {
// service exists (serviceName provided)
// If pricing already exists as ACTIVE, we disallow
- if (service.activePricings && service.activePricings[formattedPricingVersion]) {
+ if (service.activePricings && service.activePricings.get(formattedPricingVersion)) {
throw new Error(
`Pricing version ${uploadedPricing.version} already exists for service ${serviceName}`
);
}
// If pricing exists in archived, rename archived entry to free the key
- const archivedExists = service.archivedPricings && service.archivedPricings[formattedPricingVersion];
+ const archivedExists =
+ service.archivedPricings && service.archivedPricings.get(formattedPricingVersion);
const updatePayload: any = {};
if (archivedExists) {
const newKey = `${formattedPricingVersion}_${Date.now()}`;
- updatePayload[`archivedPricings.${newKey}`] = service.archivedPricings[formattedPricingVersion];
+ updatePayload[`archivedPricings.${newKey}`] =
+ service.archivedPricings!.get(formattedPricingVersion);
updatePayload[`archivedPricings.${formattedPricingVersion}`] = undefined;
}
// Step 3: Create the service as it does not exist and add the pricing
const savedPricing = await this.pricingRepository.create(pricingData);
-
+
if (!savedPricing) {
throw new Error(`Pricing ${uploadedPricing.version} not saved`);
}
@@ -381,24 +411,27 @@ class ServiceService {
for (const key of Object.keys(service.activePricings)) {
if (key === formattedPricingVersion) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = service.activePricings[key];
+ newArchived.set(newKey, service.activePricings.get(key)!);
} else {
- if (newArchived[key]) {
+ if (newArchived.has(key)) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = service.activePricings[key];
+ newArchived.set(newKey, service.activePricings.get(key)!);
} else {
- newArchived[key] = service.activePricings[key];
+ newArchived.set(key, service.activePricings.get(key)!);
}
}
}
}
updatePayload.disabled = false;
- updatePayload.activePricings = {
- [formattedPricingVersion]: {
- id: savedPricing.id,
- },
- };
+ updatePayload.activePricings = new Map([
+ [
+ formattedPricingVersion,
+ {
+ id: savedPricing.id,
+ },
+ ],
+ ]);
updatePayload.archivedPricings = newArchived;
} else {
// Normal update: keep existing active pricings and just add the new one
@@ -407,7 +440,11 @@ class ServiceService {
};
}
- const updatedService = await this.serviceRepository.update(service.name, updatePayload);
+ const updatedService = await this.serviceRepository.update(
+ service.name,
+ updatePayload,
+ organizationId
+ );
service = updatedService;
}
@@ -440,14 +477,22 @@ class ServiceService {
return service;
}
- async _createFromUrl(pricingUrl: string, serviceName?: string) {
+ async _createFromUrl(pricingUrl: string, organizationId: string, serviceName?: string) {
const uploadedPricing: Pricing = await this._getPricingFromRemoteUrl(pricingUrl);
const formattedPricingVersion = escapeVersion(uploadedPricing.version);
if (!serviceName) {
// Create a new service or re-enable a disabled one
- const existingEnabled = await this.serviceRepository.findByName(uploadedPricing.saasName, false);
- const existingDisabled = await this.serviceRepository.findByName(uploadedPricing.saasName, true);
+ const existingEnabled = await this.serviceRepository.findByName(
+ uploadedPricing.saasName,
+ organizationId,
+ false
+ );
+ const existingDisabled = await this.serviceRepository.findByName(
+ uploadedPricing.saasName,
+ organizationId,
+ true
+ );
if (existingEnabled) {
throw new Error(`Invalid request: Service ${uploadedPricing.saasName} already exists`);
@@ -466,13 +511,13 @@ class ServiceService {
for (const key of Object.keys(existingDisabled.activePricings)) {
if (key === formattedPricingVersion) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = existingDisabled.activePricings[key];
+ newArchived.set(newKey, existingDisabled.activePricings.get(key)!);
} else {
- if (newArchived[key]) {
+ if (newArchived.has(key)) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = existingDisabled.activePricings[key];
+ newArchived.set(newKey, existingDisabled.activePricings.get(key)!);
} else {
- newArchived[key] = existingDisabled.activePricings[key];
+ newArchived.set(key, existingDisabled.activePricings.get(key)!);
}
}
}
@@ -480,6 +525,7 @@ class ServiceService {
const updateData: any = {
disabled: false,
+ organizationId: organizationId,
activePricings: {
[formattedPricingVersion]: {
url: pricingUrl,
@@ -488,7 +534,11 @@ class ServiceService {
archivedPricings: newArchived,
};
- const updated = await this.serviceRepository.update(existingDisabled.name, updateData);
+ const updated = await this.serviceRepository.update(
+ existingDisabled.name,
+ updateData,
+ organizationId
+ );
if (!updated) {
throw new Error(`Service ${uploadedPricing.saasName} not updated`);
}
@@ -498,6 +548,8 @@ class ServiceService {
const serviceData = {
name: uploadedPricing.saasName,
+ disabled: false,
+ organizationId: organizationId,
activePricings: {
[formattedPricingVersion]: {
url: pricingUrl,
@@ -506,10 +558,10 @@ class ServiceService {
};
const service = await this.serviceRepository.create(serviceData);
-
+
// Emit pricing creation event
this.eventService.emitPricingCreatedMessage(service.name, uploadedPricing.version);
-
+
return service;
} else {
if (uploadedPricing.saasName !== serviceName) {
@@ -518,13 +570,13 @@ class ServiceService {
);
}
// Update an existing service
- const service = await this.serviceRepository.findByName(serviceName);
+ const service = await this.serviceRepository.findByName(serviceName, organizationId);
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
// If already active, reject
- if (service.activePricings && service.activePricings[formattedPricingVersion]) {
+ if (service.activePricings && service.activePricings.has(formattedPricingVersion)) {
throw new Error(
`Pricing version ${uploadedPricing.version} already exists for service ${serviceName}`
);
@@ -533,9 +585,10 @@ class ServiceService {
const updatePayload: any = {};
// If exists in archived, rename archived entry first
- if (service.archivedPricings && service.archivedPricings[formattedPricingVersion]) {
+ if (service.archivedPricings && service.archivedPricings.has(formattedPricingVersion)) {
const newKey = `${formattedPricingVersion}_${Date.now()}`;
- updatePayload[`archivedPricings.${newKey}`] = service.archivedPricings[formattedPricingVersion];
+ updatePayload[`archivedPricings.${newKey}`] =
+ service.archivedPricings.get(formattedPricingVersion);
updatePayload[`archivedPricings.${formattedPricingVersion}`] = undefined;
}
@@ -553,19 +606,20 @@ class ServiceService {
for (const key of Object.keys(service.activePricings)) {
if (key === formattedPricingVersion) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = service.activePricings[key];
+ newArchived.set(newKey, service.activePricings.get(key)!);
} else {
- if (newArchived[key]) {
+ if (newArchived.has(key)) {
const newKey = `${key}_${Date.now()}`;
- newArchived[newKey] = service.activePricings[key];
+ newArchived.set(newKey, service.activePricings.get(key)!);
} else {
- newArchived[key] = service.activePricings[key];
+ newArchived.set(key, service.activePricings.get(key)!);
}
}
}
}
updatePayload.disabled = false;
+ updatePayload.organizationId = organizationId;
updatePayload.activePricings = {
[formattedPricingVersion]: {
url: pricingUrl,
@@ -578,40 +632,115 @@ class ServiceService {
};
}
- const updatedService = await this.serviceRepository.update(service.name, updatePayload);
+ const updatedService = await this.serviceRepository.update(
+ service.name,
+ updatePayload,
+ organizationId
+ );
if (!updatedService) {
- throw new Error(`Service ${serviceName} not updated with pricing ${uploadedPricing.version}`);
+ throw new Error(
+ `Service ${serviceName} not updated with pricing ${uploadedPricing.version}`
+ );
}
resetEscapeVersionInService(updatedService);
-
+
// Emit pricing creation event
this.eventService.emitPricingCreatedMessage(service.name, uploadedPricing.version);
-
+
return updatedService;
}
}
- async update(serviceName: string, newServiceData: any) {
-
- let service = await this.cacheService.get(`service.${serviceName}`);
+ async update(serviceName: string, newServiceData: any, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
+ let dataToUpdate: any = {};
+ let contractsToRemoveService: LeanContract[] = [];
+ let contractsToUpdateOrgId: LeanContract[] = [];
if (!service) {
- service = await this.serviceRepository.findByName(serviceName);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
}
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
- const updatedService = await this.serviceRepository.update(service.name, newServiceData);
if (newServiceData.name && newServiceData.name !== service.name) {
+ const existingService = await this.serviceRepository.findByName(
+ newServiceData.name,
+ organizationId
+ );
+ if (existingService) {
+ throw new Error(`CONFLICT: Service name ${newServiceData.name} already exists`);
+ }
+ dataToUpdate.name = newServiceData.name;
+ }
+
+ if (newServiceData.organizationId && newServiceData.organizationId !== organizationId) {
+ const organization = await this.organizationRepository.findById(newServiceData.organizationId);
+ if (!organization) {
+ throw new Error(`INVALID DATA: Organization with id ${newServiceData.organizationId} not found`);
+ }
+
+ const contracts = await this.contractRepository.findByFilters({filters: {services: [service.name]}, organizationId});
+
+ for (const contract of contracts) {
+ if (Object.keys(contract.contractedServices).length > 1) {
+ contractsToRemoveService.push(contract);
+ }else{
+ if (dataToUpdate.name) {
+ contract.contractedServices[dataToUpdate.name] = contract.contractedServices[service.name];
+ delete contract.contractedServices[service.name];
+ }
+ contractsToUpdateOrgId.push(contract);
+ }
+ }
+
+ dataToUpdate.organizationId = newServiceData.organizationId;
+ }
+
+ const updatedService = await this.serviceRepository.update(
+ service.name,
+ dataToUpdate,
+ organizationId
+ );
+
+ if (dataToUpdate.name) {
// If the service name has changed, we need to update the cache key
- await this.cacheService.del(`service.${service.name}`);
- serviceName = newServiceData.name;
+ await this.cacheService.del(cacheKey);
+ serviceName = dataToUpdate.name;
+
+ await this.contractRepository.changeServiceName(service.name, dataToUpdate.name, organizationId);
+ const updatedContracts = await this.contractRepository.findByFilters({filters: {services: [dataToUpdate.name]}, organizationId: dataToUpdate.organizationId || organizationId});
+
+ await this.cacheService.delMany(updatedContracts.map(c => `contracts.${c.userContact.userId}`));
}
- await this.cacheService.set(`service.${serviceName}`, updatedService, 3600, true);
+
+ if (dataToUpdate.organizationId) {
+ for (const contract of contractsToRemoveService) {
+ delete contract.contractedServices[service.name];
+ }
+ await this.contractRepository.bulkUpdate(contractsToRemoveService);
+
+ for (const contract of contractsToUpdateOrgId) {
+ contract.organizationId = dataToUpdate.organizationId;
+ }
+ await this.contractRepository.bulkUpdate(contractsToUpdateOrgId);
+
+ await this.cacheService.delMany(contractsToRemoveService.map(c => `contracts.${c.userContact.userId}`));
+ await this.cacheService.delMany(contractsToUpdateOrgId.map(c => `contracts.${c.userContact.userId}`));
+ }
+
+ let newCacheKey = `service.${organizationId}.${serviceName}`;
+
+ if (newServiceData.organizationId && newServiceData.organizationId !== organizationId) {
+ newCacheKey = `service.${newServiceData.organizationId}.${serviceName}`;
+ }
+
+ await this.cacheService.set(newCacheKey, updatedService, 3600, true);
return updatedService;
}
@@ -620,35 +749,36 @@ class ServiceService {
serviceName: string,
pricingVersion: string,
newAvailability: 'active' | 'archived',
- fallBackSubscription: FallBackSubscription
+ fallBackSubscription: FallBackSubscription,
+ organizationId: string
) {
-
- let service = await this.cacheService.get(`service.${serviceName}`);
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
if (!service) {
- service = await this.serviceRepository.findByName(serviceName);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
}
const formattedPricingVersion = escapeVersion(pricingVersion);
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
// If newAvailability is the same as the current one, return the service
if (
- (newAvailability === 'active' && service.activePricings[formattedPricingVersion]) ||
+ (newAvailability === 'active' && service.activePricings.has(formattedPricingVersion)) ||
(newAvailability === 'archived' &&
service.archivedPricings &&
- service.archivedPricings[formattedPricingVersion])
+ service.archivedPricings.has(formattedPricingVersion))
) {
return service;
}
if (
newAvailability === 'archived' &&
- Object.keys(service.activePricings).length === 1 &&
- service.activePricings[formattedPricingVersion]
+ service.activePricings.size === 1 &&
+ service.activePricings.has(formattedPricingVersion)
) {
throw new Error(`You cannot archive the last active pricing for service ${serviceName}`);
}
@@ -660,8 +790,8 @@ class ServiceService {
}
const pricingLocator =
- service.activePricings[formattedPricingVersion] ??
- service.archivedPricings[formattedPricingVersion];
+ service.activePricings.get(formattedPricingVersion) ??
+ service.archivedPricings.get(formattedPricingVersion);
if (!pricingLocator) {
throw new Error(`Pricing version ${pricingVersion} not found for service ${serviceName}`);
@@ -670,23 +800,31 @@ class ServiceService {
let updatedService;
if (newAvailability === 'active') {
- updatedService = await this.serviceRepository.update(service.name, {
- [`activePricings.${formattedPricingVersion}`]: pricingLocator,
- [`archivedPricings.${formattedPricingVersion}`]: undefined,
- });
+ updatedService = await this.serviceRepository.update(
+ service.name,
+ {
+ [`activePricings.${formattedPricingVersion}`]: pricingLocator,
+ [`archivedPricings.${formattedPricingVersion}`]: undefined,
+ },
+ organizationId
+ );
// Emitir evento de cambio de pricing (activación)
this.eventService.emitPricingActivedMessage(service.name, pricingVersion);
- await this.cacheService.set(`service.${serviceName}`, updatedService, 3600, true);
+ await this.cacheService.set(cacheKey, updatedService, 3600, true);
} else {
- updatedService = await this.serviceRepository.update(service.name, {
- [`activePricings.${formattedPricingVersion}`]: undefined,
- [`archivedPricings.${formattedPricingVersion}`]: pricingLocator,
- });
+ updatedService = await this.serviceRepository.update(
+ service.name,
+ {
+ [`activePricings.${formattedPricingVersion}`]: undefined,
+ [`archivedPricings.${formattedPricingVersion}`]: pricingLocator,
+ },
+ organizationId
+ );
// Emitir evento de cambio de pricing (archivado)
this.eventService.emitPricingArchivedMessage(service.name, pricingVersion);
- await this.cacheService.set(`service.${serviceName}`, updatedService, 3600, true);
+ await this.cacheService.set(cacheKey, updatedService, 3600, true);
if (
fallBackSubscription &&
@@ -701,7 +839,8 @@ class ServiceService {
await this._novateContractsToLatestVersion(
service.name.toLowerCase(),
escapeVersion(pricingVersion),
- fallBackSubscription
+ fallBackSubscription,
+ organizationId
);
}
@@ -712,57 +851,106 @@ class ServiceService {
return updatedService;
}
- async prune() {
- const result = await this.serviceRepository.prune();
+ async prune(organizationId?: string) {
+ if (organizationId) {
+ const organizationServices: LeanService[] = await this.index({}, organizationId);
+ const organizationServiceNames: string[] = organizationServices.map(s => s.name) as string[];
+
+ for (const serviceName of organizationServiceNames) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ await this.cacheService.del(cacheKey);
+ const contractNovationResult = await this._removeServiceFromContracts(
+ serviceName,
+ organizationId
+ );
+
+ if (!contractNovationResult) {
+ throw new Error(`Failed to remove service ${serviceName} from contracts`);
+ }
+ }
+ }
+
+ const result = await this.serviceRepository.prune(organizationId);
return result;
}
- async disable(serviceName: string) {
- let service = await this.cacheService.get(`service.${serviceName}`);
+ async destroy(serviceName: string, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
if (!service) {
- service = await this.serviceRepository.findByName(serviceName);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
}
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
- const contractNovationResult = await this._removeServiceFromContracts(service.name);
+ const contractNovationResult = await this._removeServiceFromContracts(
+ service.name,
+ organizationId
+ );
if (!contractNovationResult) {
throw new Error(`Failed to remove service ${serviceName} from contracts`);
}
- const result = await this.serviceRepository.disable(service.name);
-
- this.eventService.emitServiceDisabledMessage(service.name);
- this.cacheService.del(`service.${serviceName}`);
+ const result = await this.serviceRepository.destroy(serviceName, organizationId);
+ this.cacheService.del(cacheKey);
return result;
}
- async destroyPricing(serviceName: string, pricingVersion: string) {
+ async disable(serviceName: string, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
- let service = await this.cacheService.get(`service.${serviceName}`);
+ if (!service) {
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
+ }
+
+ if (!service) {
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
+ }
+
+ const contractNovationResult = await this._removeServiceFromContracts(
+ service.name,
+ organizationId
+ );
+
+ if (!contractNovationResult) {
+ throw new Error(`Failed to remove service ${serviceName} from contracts`);
+ }
+
+ const result = await this.serviceRepository.disable(service.name, organizationId);
+
+ this.eventService.emitServiceDisabledMessage(service.name);
+ this.cacheService.del(cacheKey);
+
+ return result;
+ }
+
+ async destroyPricing(serviceName: string, pricingVersion: string, organizationId: string) {
+ const cacheKey = `service.${organizationId}.${serviceName}`;
+ let service = await this.cacheService.get(cacheKey);
if (!service) {
- service = await this.serviceRepository.findByName(serviceName);
+ service = await this.serviceRepository.findByName(serviceName, organizationId);
}
if (!service) {
- throw new Error(`Service ${serviceName} not found`);
+ throw new Error(`NOT FOUND: Service with name ${serviceName}`);
}
const formattedPricingVersion = escapeVersion(pricingVersion);
if (service.activePricings[formattedPricingVersion]) {
throw new Error(
- `Forbidden: You cannot delete an active pricing version ${pricingVersion} for service ${serviceName}. Please archive it first.`
+ `CONFLICT: You cannot delete an active pricing version ${pricingVersion} for service ${serviceName}. Please archive it first.`
);
}
- const pricingLocator = service.archivedPricings[formattedPricingVersion];
+ const pricingLocator = service.archivedPricings?.get(formattedPricingVersion);
if (!pricingLocator) {
throw new Error(
@@ -776,10 +964,14 @@ class ServiceService {
this.cacheService.del(`pricing.url.${pricingLocator.url}`);
}
- const result = await this.serviceRepository.update(service.name, {
- [`activePricings.${formattedPricingVersion}`]: undefined,
- [`archivedPricings.${formattedPricingVersion}`]: undefined,
- });
+ const result = await this.serviceRepository.update(
+ service.name,
+ {
+ [`activePricings.${formattedPricingVersion}`]: undefined,
+ [`archivedPricings.${formattedPricingVersion}`]: undefined,
+ },
+ organizationId
+ );
await this.cacheService.set(`service.${serviceName}`, result, 3600, true);
return result;
@@ -788,10 +980,14 @@ class ServiceService {
async _novateContractsToLatestVersion(
serviceName: string,
pricingVersion: string,
- fallBackSubscription: FallBackSubscription
+ fallBackSubscription: FallBackSubscription,
+ organizationId: string
): Promise {
const serviceContracts: LeanContract[] = await this.contractRepository.findByFilters({
- services: [serviceName],
+ filters: {
+ services: [serviceName],
+ },
+ organizationId: organizationId,
});
if (Object.keys(fallBackSubscription).length === 0) {
@@ -808,7 +1004,7 @@ class ServiceService {
return;
}
- const serviceLatestPricing = await this._getLatestActivePricing(serviceName);
+ const serviceLatestPricing = await this._getLatestActivePricing(serviceName, organizationId);
if (!serviceLatestPricing) {
throw new Error(`No active pricing found for service ${serviceName}`);
@@ -820,7 +1016,7 @@ class ServiceService {
pricingVersionContracts.forEach(contract => {
contract.contractedServices[serviceName] = serviceLatestPricing.version;
contract.subscriptionPlans[serviceName] = fallBackSubscription.subscriptionPlan;
- contract.subscriptionAddOns[serviceName] = fallBackSubscription.subscriptionAddOns;
+ contract.subscriptionAddOns[serviceName] = fallBackSubscription.subscriptionAddOns ?? {};
try {
isSubscriptionValidInPricing(
@@ -853,8 +1049,11 @@ class ServiceService {
}
}
- async _getLatestActivePricing(serviceName: string): Promise {
- const pricings = await this.indexPricings(serviceName, 'active');
+ async _getLatestActivePricing(
+ serviceName: string,
+ organizationId: string
+ ): Promise {
+ const pricings = await this.indexPricings(serviceName, 'active', organizationId);
const sortedPricings = pricings.sort((a, b) => {
// Sort by createdAt date (descending - newest first)
@@ -910,74 +1109,77 @@ class ServiceService {
return retrievePricingFromText(remotePricingYaml);
}
- async _removeServiceFromContracts(serviceName: string): Promise {
- const contracts: LeanContract[] = await this.contractRepository.findByFilters({});
- const novatedContracts: LeanContract[] = [];
- const contractsToDisable: LeanContract[] = [];
-
- for (const contract of contracts) {
- // Remove this service from the subscription objects
- const newSubscription: Record = {
- contractedServices: {},
- subscriptionPlans: {},
- subscriptionAddOns: {},
- };
-
- // Rebuild subscription objects without the service to be removed
- for (const key in contract.contractedServices) {
- if (key !== serviceName) {
- newSubscription.contractedServices[key] = contract.contractedServices[key];
+ async _removeServiceFromContracts(serviceName: string, organizationId: string): Promise {
+ try{
+ const contracts: LeanContract[] = await this.contractRepository.findByFilters({
+ organizationId,
+ });
+ const novatedContracts: LeanContract[] = [];
+ const contractsToDisable: LeanContract[] = [];
+
+ for (const contract of contracts) {
+ // Remove this service from the subscription objects
+ const newSubscription: Record = {
+ contractedServices: {},
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ };
+
+ // Rebuild subscription objects without the service to be removed
+ for (const key in contract.contractedServices) {
+ if (key !== serviceName) {
+ newSubscription.contractedServices[key] = contract.contractedServices[key];
+ }
}
- }
-
- for (const key in contract.subscriptionPlans) {
- if (key !== serviceName) {
- newSubscription.subscriptionPlans[key] = contract.subscriptionPlans[key];
+
+ for (const key in contract.subscriptionPlans) {
+ if (key !== serviceName) {
+ newSubscription.subscriptionPlans[key] = contract.subscriptionPlans[key];
+ }
}
- }
-
- for (const key in contract.subscriptionAddOns) {
- if (key !== serviceName) {
- newSubscription.subscriptionAddOns[key] = contract.subscriptionAddOns[key];
+
+ for (const key in contract.subscriptionAddOns) {
+ if (key !== serviceName) {
+ newSubscription.subscriptionAddOns[key] = contract.subscriptionAddOns[key];
+ }
}
+
+ // Check if objects have the same content by comparing their JSON string representation
+ const hasContractChanged =
+ JSON.stringify(contract.contractedServices) !==
+ JSON.stringify(newSubscription.contractedServices);
+
+ // If objects are equal, skip this contract
+ if (!hasContractChanged) {
+ continue;
+ }
+
+ const newContract = performNovation(contract, newSubscription);
+
+ if (contract.usageLevels[serviceName]) {
+ delete contract.usageLevels[serviceName];
+ }
+
+ if (Object.keys(newSubscription.contractedServices).length === 0) {
+ newContract.usageLevels = {};
+ newContract.billingPeriod = {
+ startDate: new Date(),
+ endDate: new Date(),
+ autoRenew: false,
+ renewalDays: 0,
+ };
+
+ contractsToDisable.push(newContract);
+ continue;
+ }
+
+ novatedContracts.push(newContract);
}
- // Check if objects have the same content by comparing their JSON string representation
- const hasContractChanged =
- JSON.stringify(contract.contractedServices) !==
- JSON.stringify(newSubscription.contractedServices);
-
- // If objects are equal, skip this contract
- if (!hasContractChanged) {
- continue;
- }
-
- const newContract = performNovation(contract, newSubscription);
-
- if (contract.usageLevels[serviceName]) {
- delete contract.usageLevels[serviceName];
- }
-
- if (Object.keys(newSubscription.contractedServices).length === 0) {
- newContract.usageLevels = {};
- newContract.billingPeriod = {
- startDate: new Date(),
- endDate: new Date(),
- autoRenew: false,
- renewalDays: 0,
- };
-
- contractsToDisable.push(newContract);
- continue;
- }
-
- novatedContracts.push(newContract);
+ return true;
+ }catch(err){
+ return false;
}
-
- const resultNovations = await this.contractRepository.bulkUpdate(novatedContracts);
- const resultDisables = await this.contractRepository.bulkUpdate(contractsToDisable, true);
-
- return resultNovations && resultDisables;
}
}
diff --git a/api/src/main/services/UserService.ts b/api/src/main/services/UserService.ts
index 64a0ecf..0c641e5 100644
--- a/api/src/main/services/UserService.ts
+++ b/api/src/main/services/UserService.ts
@@ -1,19 +1,31 @@
import container from '../config/container';
import UserRepository from '../repositories/mongoose/UserRepository';
-import { LeanUser, Role, USER_ROLES } from '../types/models/User';
+import { LeanUser } from '../types/models/User';
+import { UserRole, USER_ROLES } from '../types/permissions';
import { hashPassword } from '../utils/users/helpers';
+import OrganizationService from './OrganizationService';
class UserService {
private userRepository: UserRepository;
+ private organizationService: OrganizationService;
constructor() {
this.userRepository = container.resolve('userRepository');
+ this.organizationService = container.resolve('organizationService');
+ }
+
+ async getUsers(query: string = '', limit: number = 10, offset: number = 0): Promise {
+ return await this.userRepository.find(query.trim(), limit, offset);
+ }
+
+ async countUsers(query: string = '') {
+ return this.userRepository.count(query.trim());
}
async findByUsername(username: string) {
const user = await this.userRepository.findByUsername(username);
if (!user) {
- throw new Error('User not found');
+ throw new Error('INVALID DATA: User not found');
}
return user;
}
@@ -21,7 +33,7 @@ class UserService {
async findByApiKey(apiKey: string) {
const user = await this.userRepository.findByApiKey(apiKey);
if (!user) {
- throw new Error('Invalid API Key');
+ throw new Error('INVALID DATA: Invalid API Key');
}
return user;
}
@@ -30,49 +42,56 @@ class UserService {
const existingUser = await this.userRepository.findByUsername(userData.username);
if (existingUser) {
- throw new Error('There is already a user with the username that you are trying to set');
+ throw new Error('INVALID DATA: There is already a user with the username that you are trying to set');
}
// Stablish a default role if not provided
- if (!userData.role) {
+ if (!creatorData || !userData.role) {
userData.role = USER_ROLES[USER_ROLES.length - 1];
}
- if (creatorData.role !== 'ADMIN' && userData.role === 'ADMIN') {
- throw new Error('Not enough permissions: Only admins can create other admins.');
+ if (creatorData && (creatorData.role !== 'ADMIN' && userData.role === 'ADMIN')) {
+ throw new Error('PERMISSION ERROR: Only admins can create other admins.');
}
- return this.userRepository.create(userData);
+ const createdUser: LeanUser = await this.userRepository.create(userData);
+
+ await this.organizationService.create(
+ { name: `${createdUser.username}'s Organization`, owner: createdUser.username, default: true },
+ createdUser
+ );
+
+ return createdUser;
}
async update(username: string, userData: any, creatorData: LeanUser) {
- if (creatorData.role !== 'ADMIN' && userData.role === 'ADMIN') {
- throw new Error('Not enough permissions: Only admins can change roles to admin.');
+ if (creatorData && (creatorData.role !== 'ADMIN' && userData.role === 'ADMIN')) {
+ throw new Error('PERMISSION ERROR: Only admins can change roles to admin.');
}
const user = await this.userRepository.findByUsername(username);
if (!user) {
- throw new Error('User not found');
+ throw new Error('INVALID DATA: User not found');
}
-
- if (creatorData.role !== 'ADMIN' && user.role === 'ADMIN') {
- throw new Error('Not enough permissions: Only admins can update admin users.');
+
+ if (user.role === 'ADMIN' && creatorData && creatorData.role !== 'ADMIN') {
+ throw new Error('PERMISSION ERROR: Only admins can update admin users.');
}
// Validación: no permitir degradar al último admin
if (user.role === 'ADMIN' && userData.role && userData.role !== 'ADMIN') {
const allUsers = await this.userRepository.findAll();
- const adminCount = allUsers.filter(u => u.role === 'ADMIN' && u.username !== username).length;
+ const adminCount = allUsers.filter((u: LeanUser) => u.role === 'ADMIN' && u.username !== username).length;
if (adminCount < 1) {
- throw new Error('There must always be at least one ADMIN user in the system.');
+ throw new Error('PERMISSION ERROR: There must always be at least one ADMIN user in the system.');
}
}
if (userData.username){
const existingUser = await this.userRepository.findByUsername(userData.username);
if (existingUser) {
- throw new Error('There is already a user with the username that you are trying to set');
+ throw new Error('INVALID DATA: There is already a user with the username that you are trying to set');
}
}
@@ -80,38 +99,54 @@ class UserService {
userData.password = await hashPassword(userData.password);
}
- return await this.userRepository.update(username, userData);
+ const updatedUser = await this.userRepository.update(username, userData);
+
+ // If the username was changed, update the owner field in the organizations owned by the user
+ if (userData.username && userData.username !== username) {
+ await this.organizationService.updateUsername(username, userData.username);
+ }
+
+ return updatedUser;
}
- async regenerateApiKey(username: string): Promise {
+ async regenerateApiKey(username: string, reqUser: LeanUser): Promise {
+ if (reqUser.username !== username && reqUser.role !== 'ADMIN') {
+ throw new Error('PERMISSION ERROR: Only admins can regenerate API keys for other users.');
+ }
+
const newApiKey = await this.userRepository.regenerateApiKey(username);
+
if (!newApiKey) {
throw new Error('API Key could not be regenerated');
}
+
return newApiKey;
}
- async changeRole(username: string, role: Role, creatorData: LeanUser) {
+ async changeRole(username: string, role: UserRole, creatorData: LeanUser) {
if (creatorData.role !== 'ADMIN' && role === 'ADMIN') {
- throw new Error('Not enough permissions: Only admins can assign the role ADMIN.');
+ throw new Error('PERMISSION ERROR: Only admins can assign the role ADMIN.');
+ }
+
+ if (creatorData.role === 'USER' && creatorData.username !== username){
+ throw new Error('PERMISSION ERROR: Only admins can change roles for other users.');
}
const user = await this.userRepository.findByUsername(username);
if (!user) {
- throw new Error('User not found');
+ throw new Error('INVALID DATA: User not found');
}
if (creatorData.role !== 'ADMIN' && user.role === 'ADMIN') {
- throw new Error('Not enough permissions: Only admins can update admin users.');
+ throw new Error('PERMISSION ERROR: Only admins can update admin users.');
}
// Validación: no permitir degradar al último admin
if (user.role === 'ADMIN' && role !== 'ADMIN') {
- const allUsers = await this.userRepository.findAll();
- const adminCount = allUsers.filter(u => u.role === 'ADMIN' && u.username !== username).length;
- if (adminCount < 1) {
- throw new Error('There must always be at least one ADMIN user in the system.');
+ const allAdmins = await this.userRepository.findByRole("ADMIN");
+ if (allAdmins.length < 2) {
+ throw new Error('PERMISSION ERROR: There must always be at least one ADMIN user in the system.');
}
}
@@ -122,33 +157,37 @@ class UserService {
// Find user by username
const user = await this.userRepository.authenticate(username, password);
if (!user) {
- throw new Error('Invalid credentials');
+ throw new Error('INVALID DATA: Invalid credentials');
}
return user;
}
- async getAllUsers() {
- return this.userRepository.findAll();
- }
-
- async destroy(username: string) {
+ async destroy(username: string, reqUser: LeanUser) {
// Comprobar si el usuario a eliminar es admin
const user = await this.userRepository.findByUsername(username);
if (!user) {
- throw new Error('User not found');
+ throw new Error('INVALID DATA: User not found');
+ }
+
+ if (reqUser.role !== 'ADMIN' && user.role === 'ADMIN') {
+ throw new Error('PERMISSION ERROR: Only admins can delete admin users.');
}
+
if (user.role === 'ADMIN') {
// Contar admins restantes
const allUsers = await this.userRepository.findAll();
- const adminCount = allUsers.filter(u => u.role === 'ADMIN' && u.username !== username).length;
+ const adminCount = allUsers.filter((u: LeanUser) => u.role === 'ADMIN' && u.username !== username).length;
if (adminCount < 1) {
- throw new Error('There must always be at least one ADMIN user in the system.');
+ throw new Error('PERMISSION ERROR: There must always be at least one ADMIN user in the system.');
}
}
+ // Remove user from organizations and reassign/delete owned orgs when needed
+ await this.organizationService.removeUserFromOrganizations(username, { allowDeleteDefault: true, actingUser: reqUser });
+
const result = await this.userRepository.destroy(username);
if (!result) {
- throw new Error('User not found');
+ throw new Error('INVALID DATA: User not found');
}
return true;
}
diff --git a/api/src/main/services/validation/ContractServiceValidation.ts b/api/src/main/services/validation/ContractServiceValidation.ts
index c873a7a..82d525b 100644
--- a/api/src/main/services/validation/ContractServiceValidation.ts
+++ b/api/src/main/services/validation/ContractServiceValidation.ts
@@ -19,5 +19,9 @@ export function validateContractQueryFilters(contractQueryFilters: ContractQuery
errors.push("Invalid sort field. Please use one of the following: firstName, lastName, username, email");
}
+ if (contractQueryFilters.order && !["asc", "desc"].includes(contractQueryFilters.order)) {
+ errors.push("Invalid order value. Please use 'asc' or 'desc'");
+ }
+
return errors;
}
\ No newline at end of file
diff --git a/api/src/main/services/validation/OrganizationServiceValidations.ts b/api/src/main/services/validation/OrganizationServiceValidations.ts
new file mode 100644
index 0000000..65a1c15
--- /dev/null
+++ b/api/src/main/services/validation/OrganizationServiceValidations.ts
@@ -0,0 +1,10 @@
+function validateOrganizationData(data: any): void {
+ if (!data.name || typeof data.name !== 'string') {
+ throw new Error('Invalid or missing organization name.');
+ }
+ if (!data.owner || typeof data.owner !== 'string') {
+ throw new Error('Invalid or missing organization owner.');
+ }
+}
+
+export { validateOrganizationData };
\ No newline at end of file
diff --git a/api/src/main/types/express.d.ts b/api/src/main/types/express.d.ts
new file mode 100644
index 0000000..02577ec
--- /dev/null
+++ b/api/src/main/types/express.d.ts
@@ -0,0 +1,36 @@
+/**
+ * TypeScript declaration file to extend Express types
+ */
+
+import { LeanUser } from './models/User';
+import { OrganizationApiKeyRole } from '../config/permissions';
+
+declare global {
+ namespace Express {
+ interface Request {
+ /**
+ * Populated when authenticated with a User API Key
+ * Contains user information including username, role, etc.
+ */
+ user?: LeanUser;
+
+ /**
+ * Populated when authenticated with an Organization API Key
+ * Contains organization context and API key role
+ */
+ org?: {
+ id: string;
+ name: string;
+ members: {username: string, role: string}[];
+ role: OrganizationApiKeyRole;
+ };
+
+ /**
+ * Indicates the type of authentication used
+ */
+ authType?: 'user' | 'organization';
+ }
+ }
+}
+
+export {};
diff --git a/api/src/main/types/models/Contract.ts b/api/src/main/types/models/Contract.ts
index 02281d1..dd44a2c 100644
--- a/api/src/main/types/models/Contract.ts
+++ b/api/src/main/types/models/Contract.ts
@@ -28,6 +28,8 @@ export interface LeanContract {
renewalDays: number;
};
usageLevels: Record>;
+ organizationId: string;
+ groupId?: string;
contractedServices: Record;
subscriptionPlans: Record;
subscriptionAddOns: Record>;
@@ -40,6 +42,8 @@ export interface ContractQueryFilters {
lastName?: string;
email?: string;
serviceName?: string; // Nuevo parámetro para filtrar por servicio contratado
+ organizationId?: string;
+ groupId?: string; // Nuevo parámetro para filtrar por grupo
page?: number;
offset?: number;
limit?: number;
@@ -64,6 +68,8 @@ export interface ContractToCreate {
autoRenew?: boolean;
renewalDays?: number;
};
+ organizationId: string;
+ groupId?: string;
contractedServices: Record; // service name → version
subscriptionPlans: Record; // service name → plan name
subscriptionAddOns: Record>; // service name → { addOn: count }
diff --git a/api/src/main/types/models/Organization.ts b/api/src/main/types/models/Organization.ts
new file mode 100644
index 0000000..bbb45d6
--- /dev/null
+++ b/api/src/main/types/models/Organization.ts
@@ -0,0 +1,29 @@
+import { OrganizationApiKeyRole } from "../../types/permissions";
+
+export interface LeanOrganization {
+ id?: string;
+ name: string;
+ owner: string;
+ default?: boolean;
+ apiKeys: LeanApiKey[];
+ members: OrganizationMember[];
+}
+
+export interface LeanApiKey {
+ key: string;
+ scope: OrganizationApiKeyRole;
+}
+
+export type OrganizationUserRole = 'OWNER' | 'ADMIN' | 'MANAGER' | 'EVALUATOR';
+
+export interface OrganizationFilter {
+ owner?: string;
+ username?: string;
+ default?: boolean;
+ name?: string;
+}
+
+export interface OrganizationMember {
+ username: string;
+ role: OrganizationUserRole;
+}
\ No newline at end of file
diff --git a/api/src/main/types/models/Service.ts b/api/src/main/types/models/Service.ts
index 998e3e9..8b3d5d9 100644
--- a/api/src/main/types/models/Service.ts
+++ b/api/src/main/types/models/Service.ts
@@ -4,9 +4,12 @@ export interface PricingEntry {
}
export interface LeanService {
+ id?: string;
name: string;
- activePricings: Record;
- archivedPricings: Record;
+ disabled: boolean;
+ organizationId: string;
+ activePricings: Map;
+ archivedPricings?: Map;
}
export type ServiceQueryFilters = {
diff --git a/api/src/main/types/models/User.ts b/api/src/main/types/models/User.ts
index c746529..1b6ac06 100644
--- a/api/src/main/types/models/User.ts
+++ b/api/src/main/types/models/User.ts
@@ -1,36 +1,10 @@
+import { UserRole } from '../../types/permissions';
+
export interface LeanUser {
id: string;
username: string;
password: string;
apiKey: string;
- role: Role;
-}
-
-export type Role = 'ADMIN' | 'MANAGER' | 'EVALUATOR';
-export type Module = 'users' | 'services' | 'contracts' | 'features' | '*';
-export type RestOperation = 'GET' | 'POST' | 'PUT' | 'DELETE';
-
-export interface RolePermissions {
- allowAll?: boolean;
- allowedMethods?: Partial>;
- blockedMethods?: Partial>;
-}
-
-export const USER_ROLES: Role[] = ['ADMIN', 'MANAGER', 'EVALUATOR'];
-
-export const ROLE_PERMISSIONS: Record = {
- 'ADMIN': {
- allowAll: true
- },
- 'MANAGER': {
- blockedMethods: {
- 'DELETE': ['*']
- }
- },
- 'EVALUATOR': {
- allowedMethods: {
- 'GET': ['services', 'features'],
- 'POST': ['features']
- }
- }
-};
\ No newline at end of file
+ role: UserRole;
+ orgRole?: string;
+}
\ No newline at end of file
diff --git a/api/src/main/types/permissions.ts b/api/src/main/types/permissions.ts
new file mode 100644
index 0000000..6bf7460
--- /dev/null
+++ b/api/src/main/types/permissions.ts
@@ -0,0 +1,14 @@
+export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
+export type UserRole = 'ADMIN' | 'USER';
+export type OrganizationApiKeyRole = 'ALL' | 'MANAGEMENT' | 'EVALUATION';
+
+export interface RoutePermission {
+ path: string;
+ methods: HttpMethod[];
+ allowedUserRoles?: UserRole[];
+ allowedOrgRoles?: OrganizationApiKeyRole[];
+ requiresUser?: boolean; // If true, only user API keys are allowed (not org keys)
+ isPublic?: boolean; // If true, no authentication required
+}
+
+export const USER_ROLES: UserRole[] = ['ADMIN', 'USER'];
\ No newline at end of file
diff --git a/api/src/main/utils/contracts/novation.ts b/api/src/main/utils/contracts/novation.ts
index 169f20b..50a2087 100644
--- a/api/src/main/utils/contracts/novation.ts
+++ b/api/src/main/utils/contracts/novation.ts
@@ -1,13 +1,14 @@
import { addDays } from "date-fns";
import { LeanContract } from "../../types/models/Contract";
import { escapeContractedServiceVersions } from "./helpers";
+import { convertKeysToLowercase } from "../helpers";
export function performNovation(contract: LeanContract, newSubscription: any): LeanContract {
const newContract: LeanContract = {
...contract,
- contractedServices: escapeContractedServiceVersions(newSubscription.contractedServices),
- subscriptionPlans: newSubscription.subscriptionPlans,
- subscriptionAddOns: newSubscription.subscriptionAddOns,
+ contractedServices: convertKeysToLowercase(escapeContractedServiceVersions(newSubscription.contractedServices)),
+ subscriptionPlans: convertKeysToLowercase(newSubscription.subscriptionPlans),
+ subscriptionAddOns: convertKeysToLowercase(newSubscription.subscriptionAddOns),
};
newContract.history.push({
diff --git a/api/src/main/utils/feature-evaluation/evaluationContextsManagement.ts b/api/src/main/utils/feature-evaluation/evaluationContextsManagement.ts
index 1c9fd14..cfb057b 100644
--- a/api/src/main/utils/feature-evaluation/evaluationContextsManagement.ts
+++ b/api/src/main/utils/feature-evaluation/evaluationContextsManagement.ts
@@ -68,6 +68,26 @@ function getUserSubscriptionsFromContract(
return subscriptionsByService;
}
+function findPlanCaseInsensitive(
+ planName: string,
+ plans: Record
+): LeanPlan | undefined {
+ // First try exact match
+ if (plans[planName]) {
+ return plans[planName];
+ }
+
+ // Then try case-insensitive match
+ const normalizedPlanName = planName.toLowerCase();
+ for (const [key, plan] of Object.entries(plans)) {
+ if (key.toLowerCase() === normalizedPlanName) {
+ return plan;
+ }
+ }
+
+ return undefined;
+}
+
function mapSubscriptionsToConfigurationsByService(
userSubscriptionsByService: Record }>,
userPricings: Record
@@ -83,13 +103,14 @@ function mapSubscriptionsToConfigurationsByService(
);
}
- if (subscription.plan && pricing.plans && Object.keys(pricing.plans).length > 0 && !pricing.plans[subscription.plan!]) {
+ const plan = findPlanCaseInsensitive(subscription.plan ?? '', pricing.plans ?? {});
+
+ if (subscription.plan && pricing.plans && Object.keys(pricing.plans).length > 0 && !plan) {
throw new Error(
`Plan ${subscription.plan} not found in pricing for service ${serviceName}, whose pricing do have plans.`
);
}
- const plan = pricing.plans && subscription.plan ? pricing.plans[subscription.plan] : undefined;
const addOns = pricing.addOns
? Object.fromEntries(
Object.entries(pricing.addOns).filter(
diff --git a/api/src/main/utils/feature-evaluation/featureEvaluation.ts b/api/src/main/utils/feature-evaluation/featureEvaluation.ts
index d984061..fd0fd9e 100644
--- a/api/src/main/utils/feature-evaluation/featureEvaluation.ts
+++ b/api/src/main/utils/feature-evaluation/featureEvaluation.ts
@@ -104,7 +104,7 @@ function _evaluate(
const featureExpression: string | undefined = evaluationContext[featureId];
// Validate feature expression
- const expressionError = validateExpression(featureId, featureExpression);
+ const expressionError = validateExpression(featureId, featureExpression, pricingContext, subscriptionContext);
if (expressionError) return expressionError;
// Evaluate the expression
@@ -114,7 +114,7 @@ function _evaluate(
if (typeof evalResult !== 'boolean') {
return _createErrorResult(
'TYPE_MISMATCH',
- `Feature ${featureId} has an expression that does not return a boolean!`
+ `Feature ${featureId} has an expression that does not return a boolean! Returned value: ${evalResult} of type ${typeof evalResult}`
);
}
@@ -153,7 +153,9 @@ function _createErrorResult(code: string, message: string): FeatureEvaluationRes
function validateExpression(
featureId: string,
- expression?: string
+ expression?: string,
+ pricingContext?: PricingContext,
+ subscriptionContext?: SubscriptionContext
): FeatureEvaluationResult | null {
if (!expression) {
return _createErrorResult('PARSE_ERROR', `Feature ${featureId} has no expression defined!`);
@@ -166,6 +168,37 @@ function validateExpression(
);
}
+ const invoquedFeatures = expression.match(/pricingContext\['features'\]\['([^']+)'\]/g)?.map(match => match.match(/pricingContext\['features'\]\['([^']+)'\]/)![1]) || [];
+ const invoquedUsageLimits = expression.match(/subscriptionContext\['([^']+)'\]/g)?.map(match => match.match(/subscriptionContext\['([^']+)'\]/)![1]) || [];
+ const invoquedUsageLevels = expression.match(/pricingContext\['usageLevels'\]\['([^']+)'\]/g)?.map(match => match.match(/pricingContext\['usageLevels'\]\['([^']+)'\]/)![1]) || [];
+
+ for (const feature of invoquedFeatures) {
+ if (pricingContext?.features[feature] === undefined) {
+ return _createErrorResult(
+ 'PARSE_ERROR',
+ `Feature ${featureId} expression references feature '${feature}' that was not found in pricingContext.features.`
+ );
+ }
+ }
+
+ for (const limit of invoquedUsageLimits) {
+ if (subscriptionContext && subscriptionContext[limit] === undefined) {
+ return _createErrorResult(
+ 'PARSE_ERROR',
+ `Feature ${featureId} expression references usage limit '${limit}' that was not found in subscriptionContext.`
+ );
+ }
+ }
+
+ for (const limit of invoquedUsageLevels) {
+ if (pricingContext?.usageLimits[limit] === undefined) {
+ return _createErrorResult(
+ 'PARSE_ERROR',
+ `Feature ${featureId} expression references usage level '${limit}' that was not found in pricingContext.usageLimits.`
+ );
+ }
+ }
+
return null;
}
diff --git a/api/src/main/utils/routeMatcher.ts b/api/src/main/utils/routeMatcher.ts
new file mode 100644
index 0000000..8889794
--- /dev/null
+++ b/api/src/main/utils/routeMatcher.ts
@@ -0,0 +1,115 @@
+/**
+ * Utility for matching URL paths with wildcard patterns
+ *
+ * Supports two types of wildcards:
+ * - '*' matches exactly one path segment
+ * - '**' matches any number of path segments (must be at the end of the pattern)
+ *
+ * Examples:
+ * - Pattern '/users/*' matches '/users/john' but not '/users/john/profile'
+ * - Pattern '/organizations/**' matches '/organizations/org1', '/organizations/org1/services', etc.
+ * - Pattern '/api/v1/services/*\/contracts' matches '/api/v1/services/service1/contracts'
+ */
+
+/**
+ * Normalizes a path by removing trailing slashes and ensuring it starts with '/'
+ */
+function normalizePath(path: string): string {
+ // Remove trailing slash (except for root path)
+ let normalized = path.endsWith('/') && path.length > 1
+ ? path.slice(0, -1)
+ : path;
+
+ // Ensure it starts with '/'
+ if (!normalized.startsWith('/')) {
+ normalized = '/' + normalized;
+ }
+
+ return normalized;
+}
+
+/**
+ * Checks if a given path matches a pattern with wildcards
+ *
+ * @param pattern - The pattern to match against (can contain * and **)
+ * @param path - The actual path to check
+ * @returns true if the path matches the pattern, false otherwise
+ */
+export function matchPath(pattern: string, path: string): boolean {
+ const normalizedPattern = normalizePath(pattern);
+ const normalizedPath = normalizePath(path);
+
+ // Check if pattern ends with '/**' (matches everything with that prefix)
+ if (normalizedPattern.endsWith('/**')) {
+ const prefix = normalizedPattern.slice(0, -3); // Remove '/**'
+ return normalizedPath === prefix || normalizedPath.startsWith(prefix + '/');
+ }
+
+ // Split both pattern and path into segments
+ const patternSegments = normalizedPattern.split('/').filter(s => s.length > 0);
+ const pathSegments = normalizedPath.split('/').filter(s => s.length > 0);
+
+ // If lengths don't match and there's no '**', they can't match
+ if (patternSegments.length !== pathSegments.length) {
+ return false;
+ }
+
+ // Compare each segment
+ for (let i = 0; i < patternSegments.length; i++) {
+ const patternSegment = patternSegments[i];
+ const pathSegment = pathSegments[i];
+
+ // '*' matches any single segment
+ if (patternSegment === '*') {
+ continue;
+ }
+
+ // Exact match required
+ if (patternSegment !== pathSegment) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Finds the first matching pattern from a list of patterns
+ *
+ * @param patterns - Array of patterns to check
+ * @param path - The path to match against
+ * @returns The first matching pattern, or null if none match
+ */
+export function findMatchingPattern(patterns: string[], path: string): string | null {
+ for (const pattern of patterns) {
+ if (matchPath(pattern, path)) {
+ return pattern;
+ }
+ }
+ return null;
+}
+
+/**
+ * Extracts the base API path from the full URL path
+ * This removes the base URL prefix (e.g., '/api/v1') if present
+ *
+ * @param fullPath - The full request path
+ * @param baseUrlPath - The base URL path to remove (e.g., '/api/v1')
+ * @returns The path without the base URL prefix
+ */
+export function extractApiPath(fullPath: string, baseUrlPath?: string): string {
+ const normalized = normalizePath(fullPath);
+
+ if (!baseUrlPath) {
+ return normalized;
+ }
+
+ const normalizedBase = normalizePath(baseUrlPath);
+
+ if (normalized.startsWith(normalizedBase)) {
+ const remaining = normalized.slice(normalizedBase.length);
+ return remaining || '/';
+ }
+
+ return normalized;
+}
diff --git a/api/src/main/utils/services/helpers.ts b/api/src/main/utils/services/helpers.ts
index 50815b3..e55a625 100644
--- a/api/src/main/utils/services/helpers.ts
+++ b/api/src/main/utils/services/helpers.ts
@@ -1,30 +1,37 @@
import { LeanPricing } from '../../types/models/Pricing';
-import { LeanService } from '../../types/models/Service';
+import { LeanService, PricingEntry } from '../../types/models/Service';
import { resetEscapeVersion } from '../helpers';
function resetEscapeVersionInService(service: LeanService): void {
- for (const version in service.activePricings) {
+ // Collect transformations for activePricings
+ const activeTransformations: Array<[string, string, PricingEntry]> = [];
+ for (const [version, pricing] of service.activePricings.entries()) {
const formattedVersion = resetEscapeVersion(version);
-
- if (formattedVersion !== version && service.activePricings[version]) {
- service.activePricings[formattedVersion] = {
- ...service.activePricings[version],
- };
-
- delete service.activePricings[version];
+ if (formattedVersion !== version) {
+ activeTransformations.push([version, formattedVersion, pricing]);
}
-
}
-
- for (const version in service.archivedPricings) {
- const formattedVersion = resetEscapeVersion(version);
-
- if (formattedVersion !== version && service.archivedPricings[version]) {
- service.archivedPricings[formattedVersion] = {
- ...service.archivedPricings[version],
- };
- delete service.archivedPricings[version];
+ // Apply transformations to activePricings
+ for (const [oldVersion, newVersion, pricing] of activeTransformations) {
+ service.activePricings.delete(oldVersion);
+ service.activePricings.set(newVersion, pricing);
+ }
+
+ // Collect transformations for archivedPricings
+ if (service.archivedPricings) {
+ const archivedTransformations: Array<[string, string, PricingEntry]> = [];
+ for (const [version, pricing] of service.archivedPricings.entries()) {
+ const formattedVersion = resetEscapeVersion(version);
+ if (formattedVersion !== version) {
+ archivedTransformations.push([version, formattedVersion, pricing]);
+ }
+ }
+
+ // Apply transformations to archivedPricings
+ for (const [oldVersion, newVersion, pricing] of archivedTransformations) {
+ service.archivedPricings.delete(oldVersion);
+ service.archivedPricings.set(newVersion, pricing);
}
}
}
diff --git a/api/src/main/utils/users/helpers.ts b/api/src/main/utils/users/helpers.ts
index 961cec0..0f5f968 100644
--- a/api/src/main/utils/users/helpers.ts
+++ b/api/src/main/utils/users/helpers.ts
@@ -1,8 +1,13 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
-function generateApiKey() {
- const apiKey = crypto.randomBytes(32).toString('hex');
+function generateUserApiKey() {
+ const apiKey = "usr_" + crypto.randomBytes(32).toString('hex');
+ return apiKey;
+};
+
+function generateOrganizationApiKey() {
+ const apiKey = "org_" + crypto.randomBytes(32).toString('hex');
return apiKey;
};
@@ -13,4 +18,4 @@ async function hashPassword(password: string): Promise {
return hash;
}
-export { generateApiKey, hashPassword };
\ No newline at end of file
+export { generateUserApiKey, generateOrganizationApiKey, hashPassword };
\ No newline at end of file
diff --git a/api/src/test/contract.test.ts b/api/src/test/contract.test.ts
index fcfe961..92526b2 100644
--- a/api/src/test/contract.test.ts
+++ b/api/src/test/contract.test.ts
@@ -1,920 +1,1479 @@
+import { Server } from 'http';
import request from 'supertest';
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { baseUrl, getApp, shutdownApp } from './utils/testApp';
-import { Server } from 'http';
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
import {
- createRandomContract,
- createRandomContracts,
- createRandomContractsForService,
- getAllContracts,
- getContractByUserId,
- incrementAllUsageLevel,
-} from './utils/contracts/contracts';
-import { generateContractAndService, generateNovation } from './utils/contracts/generators';
-import { addDays } from 'date-fns';
-import { UsageLevel } from '../main/types/models/Contract';
-import { TestContract } from './types/models/Contract';
-import { testUserId } from './utils/contracts/ContractTestData';
-import { cleanupAuthResources, getTestAdminApiKey, getTestAdminUser } from './utils/auth';
-
-describe('Contract API Test Suite', function () {
+ createTestOrganization,
+ deleteTestOrganization,
+ addApiKeyToOrganization,
+} from './utils/organization/organizationTestUtils';
+import { createTestService, deleteTestService, getPricingFromService } from './utils/services/serviceTestUtils';
+import { generateOrganizationApiKey } from '../main/utils/users/helpers';
+import { LeanUser } from '../main/types/models/User';
+import { LeanOrganization } from '../main/types/models/Organization';
+import { LeanService } from '../main/types/models/Service';
+import { LeanContract } from '../main/types/models/Contract';
+import { generateContract } from './utils/contracts/generators';
+import { createTestContract } from './utils/contracts/contractTestUtils';
+import ContractMongoose from '../main/repositories/mongoose/models/ContractMongoose';
+
+describe('Contract API routes', function () {
let app: Server;
- let adminApiKey: string;
+ let adminUser: LeanUser;
+ let ownerUser: LeanUser;
+ let testOrganization: LeanOrganization;
+ let testService: LeanService;
+ let testOrgApiKey: string;
+ let testContract: LeanContract;
+ const contractsToCleanup: Set = new Set();
+
+ const trackContractForCleanup = (contract?: any) => {
+ if (contract?.userContact?.userId) {
+ contractsToCleanup.add(contract.userContact.userId);
+ }
+ };
beforeAll(async function () {
app = await getApp();
- await getTestAdminUser();
- adminApiKey = await getTestAdminApiKey();
+ });
+
+ beforeEach(async function () {
+ adminUser = await createTestUser('ADMIN');
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ testService = await createTestService(testOrganization.id);
+ testOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, { key: testOrgApiKey, scope: 'ALL' });
+
+ testContract = await createTestContract(testOrganization.id!, [testService], app);
+ trackContractForCleanup(testContract);
+ });
+
+ afterEach(async function () {
+ for (const userId of contractsToCleanup) {
+ await ContractMongoose.deleteOne({ 'userContact.userId': userId });
+ }
+ contractsToCleanup.clear();
+
+ if (testService?.id) {
+ await deleteTestService(testService.name, testOrganization.id!);
+ }
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
});
afterAll(async function () {
- await cleanupAuthResources();
await shutdownApp();
});
describe('GET /contracts', function () {
- let contracts: TestContract[];
+ it('returns 200 and list of contracts with org API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey);
- beforeAll(async function () {
- contracts = await createRandomContracts(10, app);
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.some((c: LeanContract) => c.userContact.userId === testContract.userContact.userId)).toBe(true);
});
- it('Should return 200 and the contracts', async function () {
+ it('returns 200 and filters contracts by username query parameter', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .get(`${baseUrl}/contracts?username=${testContract.userContact.username}`)
+ .set('x-api-key', testOrgApiKey);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body[0].userContact.username).toBe(testContract.userContact.username);
});
- it('Should return 200: Should return filtered contracts by username query parameter', async function () {
- const allContracts = await getAllContracts(app);
- const testContract = allContracts[0];
- const username = testContract.userContact.username;
+ it('returns 200 and filters contracts by groupId within the request organization only', async function () {
+ const sharedGroupId = `shared-group-${Date.now()}`;
+
+ const orgContract = await createTestContract(testOrganization.id!, [testService], app, sharedGroupId);
+ trackContractForCleanup(orgContract);
+
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(
+ foreignOrganization.id!,
+ [foreignService],
+ app,
+ sharedGroupId
+ );
+ trackContractForCleanup(foreignContract);
const response = await request(app)
- .get(`${baseUrl}/contracts?username=${username}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .get(`${baseUrl}/contracts?groupId=${sharedGroupId}`)
+ .set('x-api-key', testOrgApiKey);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === sharedGroupId)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === testOrganization.id)).toBe(true);
expect(
- response.body.every((contract: TestContract) => contract.userContact.username === username)
- ).toBeTruthy();
+ response.body.some((c: LeanContract) => c.userContact.userId === orgContract.userContact.userId)
+ ).toBe(true);
+ expect(
+ response.body.some((c: LeanContract) => c.userContact.userId === foreignContract.userContact.userId)
+ ).toBe(false);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
- it('Should return 200: Should return filtered contracts by firstName query parameter', async function () {
- const allContracts = await getAllContracts(app);
- const testContract = allContracts[0];
- const firstName = testContract.userContact.firstName;
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).get(`${baseUrl}/contracts`);
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+
+ it('returns 200 and ADMIN user can list contracts from any organization', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts?firstName=${firstName}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', adminUser.apiKey);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
expect(
- response.body.every(
- (contract: TestContract) => contract.userContact.firstName === firstName
- )
- ).toBeTruthy();
+ response.body.some((c: LeanContract) => c.userContact.userId === testContract.userContact.userId)
+ ).toBe(true);
});
+ });
- it('Should return 200: Should return filtered contracts by lastName query parameter', async function () {
- const allContracts = await getAllContracts(app);
- const testContract = allContracts[0];
- const lastName = testContract.userContact.lastName;
+ describe('GET /organizations/:organizationId/contracts', function () {
+ it('returns 200 and contracts for specific organization with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', adminUser.apiKey);
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.some((c: LeanContract) => c.userContact.userId === testContract.userContact.userId)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === testOrganization.id)).toBe(true);
+ });
+
+ it('returns 200 and contracts for specific organization with USER user API key', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts?lastName=${lastName}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .get(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
- expect(
- response.body.every((contract: TestContract) => contract.userContact.lastName === lastName)
- ).toBeTruthy();
+ expect(response.body.some((c: LeanContract) => c.userContact.userId === testContract.userContact.userId)).toBe(true);
});
- it('Should return 200: Should return filtered contracts by email query parameter', async function () {
- const allContracts = await getAllContracts(app);
- const testContract = allContracts[0];
- const email = testContract.userContact.email;
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).get(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts`
+ );
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+
+ it('returns 403 when non-ADMIN user tries to list contracts from another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
const response = await request(app)
- .get(`${baseUrl}/contracts?email=${email}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .get(`${baseUrl}/organizations/${foreignOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeGreaterThan(0);
+ expect(response.status).toBe(403);
+
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
+ });
+
+ it('returns 200 when ADMIN user lists contracts from another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(foreignOrganization.id!, [foreignService], app);
+ trackContractForCleanup(foreignContract);
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${foreignOrganization.id}/contracts`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
expect(
- response.body.every((contract: TestContract) => contract.userContact.email === email)
- ).toBeTruthy();
+ response.body.some((c: LeanContract) => c.userContact.userId === foreignContract.userContact.userId)
+ ).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === foreignOrganization.id)).toBe(true);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
+ });
- it('Should return 200: Should paginate contracts using page and limit parameters', async function () {
- // Create additional contracts to ensure pagination
- await Promise.all([
- createRandomContract(app),
- createRandomContract(app),
- createRandomContract(app),
- ]);
+ describe('POST /contracts', function () {
+ it('returns 201 when creating a contract with org API key', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
- const limit = 2;
- const page1Response = await request(app)
- .get(`${baseUrl}/contracts?page=1&limit=${limit}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ expect(response.status).toBe(201);
+ expect(response.body.userContact.userId).toBe(contractData.userContact.userId);
+ expect(response.body.organizationId).toBe(testOrganization.id);
+ trackContractForCleanup(response.body);
+ });
- const page2Response = await request(app)
- .get(`${baseUrl}/contracts?page=2&limit=${limit}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ it('returns 409 when creating a duplicate contract for the same userId', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
- expect(page1Response.body).toBeDefined();
- expect(Array.isArray(page1Response.body)).toBeTruthy();
- expect(page1Response.body.length).toBe(limit);
+ const first = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
+ trackContractForCleanup(first.body);
- expect(page2Response.body).toBeDefined();
- expect(Array.isArray(page2Response.body)).toBeTruthy();
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
- // Check that the results from page 1 and 2 are different
- const page1Ids = page1Response.body.map(
- (contract: TestContract) => contract.userContact.userId
+ expect(response.status).toBe(409);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('returns 403 when trying to create a contract for a different organization', async function () {
+ const otherOrg = await createTestOrganization(ownerUser.username);
+
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ otherOrg.id!,
+ undefined,
+ app
);
- const page2Ids = page2Response.body.map(
- (contract: TestContract) => contract.userContact.userId
+
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('returns 400 when creating a contract with non-existent service', async function () {
+ const contractData = await generateContract(
+ { 'non-existent-service': '1.0.0' },
+ testOrganization.id!,
+ undefined,
+ app
);
- expect(page1Ids).not.toEqual(page2Ids);
+
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBeDefined();
});
- it('Should return 200: Should paginate contracts using offset and limit parameters', async function () {
- const limit = 3;
- const offsetResponse = await request(app)
- .get(`${baseUrl}/contracts?offset=3&limit=${limit}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ it('returns 400 when creating a contract with invalid service version', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: '99.99.99' },
+ testOrganization.id!,
+ undefined,
+ app
+ );
- expect(offsetResponse.body).toBeDefined();
- expect(Array.isArray(offsetResponse.body)).toBeTruthy();
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
- // Verify that this is working by comparing with a direct fetch
- const allContracts = await getAllContracts(app);
- const expectedContracts = allContracts.slice(3, 3 + limit);
- expect(offsetResponse.body.length).toBe(expectedContracts.length);
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Invalid');
});
- it('Should return 200: Should sort contracts by firstName in ascending order', async function () {
+ it('returns 422 when userContact.userId is empty', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ '',
+ app
+ );
+
const response = await request(app)
- .get(`${baseUrl}/contracts?sort=firstName&order=asc`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send(contractData);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(422);
+ expect(response.body.error).toBeDefined();
+ });
- const firstNames = response.body.map(
- (contract: TestContract) => contract.userContact.firstName
+ it('returns 401 when API key is missing', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
);
- const sortedFirstNames = [...firstNames].sort();
- expect(firstNames).toEqual(sortedFirstNames);
+
+ const response = await request(app).post(`${baseUrl}/contracts`).send(contractData);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
});
+ });
+
+ describe('POST /organizations/:organizationId/contracts', function () {
+ it('returns 201 when creating a contract with user API key', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
- it('Should return 200: Should sort contracts by lastName in descending order', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts?sort=lastName&order=desc`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(contractData);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(201);
+ expect(response.body.organizationId).toBe(testOrganization.id);
+ trackContractForCleanup(response.body);
+ });
- const lastNames = response.body.map(
- (contract: TestContract) => contract.userContact.lastName
+ it('returns 403 when organizationId in body does not match URL param', async function () {
+ const otherOrg = await createTestOrganization(ownerUser.username);
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ otherOrg.id!,
+ undefined,
+ app
);
- const sortedLastNames = [...lastNames].sort().reverse();
- expect(lastNames).toEqual(sortedLastNames);
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('Organization ID mismatch');
+
+ await deleteTestOrganization(otherOrg.id!);
});
- it('Should return 200: Should sort contracts by username by default', async function () {
+ it('returns 401 when API key is missing', async function () {
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .send(contractData);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ it('returns 403 when non-ADMIN user tries to create a contract in another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
- const usernames = response.body.map(
- (contract: TestContract) => contract.userContact.username
+ const contractData = await generateContract(
+ { [foreignService.name.toLowerCase()]: foreignService.activePricings.keys().next().value! },
+ foreignOrganization.id!,
+ undefined,
+ app
);
- const sortedUsernames = [...usernames].sort();
- expect(usernames).toEqual(sortedUsernames);
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${foreignOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
- it('Should return 200: Should enforce maximum limit value', async function () {
+ it('returns 201 when ADMIN user creates a contract in another organization (org endpoint)', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+
+ const contractData = await generateContract(
+ { [foreignService.name.toLowerCase()]: foreignService.activePricings.keys().next().value! },
+ foreignOrganization.id!,
+ undefined,
+ app
+ );
+
const response = await request(app)
- .get(`${baseUrl}/contracts?limit=200`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .post(`${baseUrl}/organizations/${foreignOrganization.id}/contracts`)
+ .set('x-api-key', adminUser.apiKey)
+ .send(contractData);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeLessThanOrEqual(100);
+ expect(response.status).toBe(201);
+ expect(response.body.organizationId).toBe(foreignOrganization.id);
+ trackContractForCleanup(response.body);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
+ });
+
+ describe('PUT /organizations/:organizationId/contracts', function () {
+ it('returns 200 and novates contracts filtered by groupId for that organization only', async function () {
+ const sharedGroupId = `bulk-org-group-${Date.now()}`;
+ const unaffectedGroupId = `bulk-org-unaffected-${Date.now()}`;
- it('Should return 200: Should return filtered contracts by serviceName query parameter', async function () {
- // First, get all contracts to find one with a specific service
- const allContracts = await getAllContracts(app);
+ const targetContract = await createTestContract(testOrganization.id!, [testService], app, sharedGroupId);
+ trackContractForCleanup(targetContract);
- // Find a contract with at least one contracted service
- const testContract = allContracts.find(
- contract => Object.keys(contract.contractedServices).length > 0
+ const unaffectedContract = await createTestContract(
+ testOrganization.id!,
+ [testService],
+ app,
+ unaffectedGroupId
+ );
+ trackContractForCleanup(unaffectedContract);
+
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(
+ foreignOrganization.id!,
+ [foreignService],
+ app,
+ sharedGroupId
+ );
+ trackContractForCleanup(foreignContract);
+
+ const novationService = await createTestService(testOrganization.id, `bulk-org-service-${Date.now()}`);
+ const pricingVersion = novationService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(
+ novationService.name,
+ pricingVersion,
+ testOrganization.id!,
+ app
);
- // Get the first serviceName from the contract
- const serviceName = Object.keys(testContract.contractedServices)[0];
+ const novationData = {
+ contractedServices: { [novationService.name.toLowerCase()]: pricingVersion },
+ subscriptionPlans: { [novationService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ };
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send({ filters: { services: [serviceName] } })
- .expect(200);
+ .put(`${baseUrl}/organizations/${testOrganization.id}/contracts?groupId=${sharedGroupId}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(novationData);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === sharedGroupId)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === testOrganization.id)).toBe(true);
expect(
- response.body.every((contract: TestContract) =>
- Object.keys(contract.contractedServices).includes(serviceName)
+ response.body.every(
+ (c: LeanContract) => c.contractedServices[novationService.name.toLowerCase()] === pricingVersion
)
- ).toBeTruthy();
+ ).toBe(true);
+
+ const unaffectedResponse = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/contracts/${unaffectedContract.userContact.userId}`)
+ .set('x-api-key', ownerUser.apiKey);
+
+ expect(unaffectedResponse.status).toBe(200);
+ expect(unaffectedResponse.body.groupId).toBe(unaffectedGroupId);
+ expect(unaffectedResponse.body.contractedServices[novationService.name.toLowerCase()]).toBeUndefined();
+
+ const foreignContractResponse = await request(app)
+ .get(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(foreignContractResponse.status).toBe(200);
+ expect(foreignContractResponse.body.organizationId).toBe(foreignOrganization.id);
+ expect(
+ foreignContractResponse.body.contractedServices[novationService.name.toLowerCase()]
+ ).toBeUndefined();
+
+ await deleteTestService(novationService.name, testOrganization.id!);
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
- it('Should return 200: Should return filtered contracts by services (array)', async function () {
- // Ensure at least one contract exists with a service
- const created = await createRandomContract(app);
- const serviceName = Object.keys(created.contractedServices)[0];
+ it('returns 400 when groupId query parameter is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({
+ contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Missing groupId');
+ });
+
+ it('returns 403 when non-ADMIN user tries to update contracts in another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(
+ foreignOrganization.id!,
+ [foreignService],
+ app,
+ `foreign-group-${Date.now()}`
+ );
+ trackContractForCleanup(foreignContract);
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send({ filters: { services: [serviceName] } })
- .expect(200);
+ .put(`${baseUrl}/organizations/${foreignOrganization.id}/contracts?groupId=${foreignContract.groupId}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({
+ contractedServices: { [foreignService.name.toLowerCase()]: foreignService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeGreaterThan(0);
- expect(
- response.body.every((c: any) => Object.keys(c.contractedServices).includes(serviceName))
- ).toBeTruthy();
+ expect(response.status).toBe(403);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
- it('Should return 200: Should return filtered contracts by services with specific versions (object)', async function () {
- // Create a set of contracts for the same service/version
- const first = await createRandomContract(app);
- const serviceName = Object.keys(first.contractedServices)[0];
- const pricingVersion = first.contractedServices[serviceName];
+ it('returns 200 when ADMIN user updates contracts in another organization by groupId', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignGroupId = `admin-bulk-${Date.now()}`;
+ const foreignContract = await createTestContract(
+ foreignOrganization.id!,
+ [foreignService],
+ app,
+ foreignGroupId
+ );
+ trackContractForCleanup(foreignContract);
+
+ const newService = await createTestService(foreignOrganization.id, `admin-service-${Date.now()}`);
+ const pricingVersion = newService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(
+ newService.name,
+ pricingVersion,
+ foreignOrganization.id!,
+ app
+ );
- // Create more contracts with the same service/version
- await createRandomContractsForService(serviceName, pricingVersion, 3, app);
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${foreignOrganization.id}/contracts?groupId=${foreignGroupId}`)
+ .set('x-api-key', adminUser.apiKey)
+ .send({
+ contractedServices: { [newService.name.toLowerCase()]: pricingVersion },
+ subscriptionPlans: { [newService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ });
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === foreignOrganization.id)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.groupId === foreignGroupId)).toBe(true);
+
+ await deleteTestService(newService.name, foreignOrganization.id!);
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
+ });
+ });
+
+ describe('GET /contracts/:userId', function () {
+ it('returns 200 and the contract for the given userId', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .query({ limit: 50 })
- .send({ filters: { services: { [serviceName]: [pricingVersion] } } })
- .expect(200);
-
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeGreaterThanOrEqual(1);
- expect(
- response.body.every(
- (c: any) => c.contractedServices && c.contractedServices[serviceName] === pricingVersion
- )
- ).toBeTruthy();
+ .get(`${baseUrl}/contracts/${testContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.userContact.userId).toBe(testContract.userContact.userId);
+ });
+
+ it('returns 404 when contract is not found', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts/non-existent-userId`)
+ .set('x-api-key', testOrgApiKey);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
});
- it('Should return 200: Should return filtered contracts by plans', async function () {
- // Create a contract and use its plan for filtering
- const created = await createRandomContract(app);
- const serviceName = Object.keys(created.subscriptionPlans)[0];
- // If no plans were set for the contract, skip this assertion (safety)
- if (!serviceName) return;
- const planName = created.subscriptionPlans[serviceName];
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).get(`${baseUrl}/contracts/some-userId`);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
+ describe('GET /organizations/:organizationId/contracts/:userId', function () {
+ it('returns 200 and the contract for the given userId with user API key', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .query({ limit: 50 })
- .send({ filters: { plans: { [serviceName]: [planName] } } })
- .expect(200);
-
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeGreaterThanOrEqual(1);
- expect(
- response.body.every(
- (c: any) => c.subscriptionPlans && c.subscriptionPlans[serviceName] === planName
+ .get(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts/${testContract.userContact.userId}`
)
- ).toBeTruthy();
- });
-
- it('Should return 200: Should return filtered contracts by addOns', async function () {
- // Find a contract with at least one addOn
- const all = await getAllContracts(app);
- const withAddOn = all.find((c: any) => Object.keys(c.subscriptionAddOns || {}).length > 0);
- if (!withAddOn) {
- // Create a contract that includes addOns by creating several contracts until one contains addOns
- const created = await createRandomContract(app);
- // Try again
- const all2 = await getAllContracts(app);
- const withAddOn2 = all2.find(
- (c: any) => Object.keys(c.subscriptionAddOns || {}).length > 0
- );
- if (!withAddOn2)
- return; // if still none, skip test
- else {
- const svc = Object.keys(withAddOn2.subscriptionAddOns)[0];
- const addOn = Object.keys(withAddOn2.subscriptionAddOns[svc])[0];
-
- const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .query({ limit: 50 })
- .send({ filters: { addOns: { [svc]: [addOn] } } })
- .expect(200);
-
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeGreaterThanOrEqual(1);
- expect(
- response.body.every(
- (c: any) =>
- c.subscriptionAddOns &&
- c.subscriptionAddOns[svc] &&
- c.subscriptionAddOns[svc][addOn] !== undefined
- )
- ).toBeTruthy();
- }
- } else {
- const svc = Object.keys(withAddOn.subscriptionAddOns)[0];
- const addOn = Object.keys(withAddOn.subscriptionAddOns[svc])[0];
-
- const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .query({ limit: 50 })
- .send({ filters: { addOns: { [svc]: [addOn] } } })
- .expect(200);
-
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBeGreaterThanOrEqual(1);
- expect(
- response.body.every(
- (c: any) =>
- c.subscriptionAddOns &&
- c.subscriptionAddOns[svc] &&
- c.subscriptionAddOns[svc][addOn] !== undefined
- )
- ).toBeTruthy();
- }
+ .set('x-api-key', ownerUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.userContact.userId).toBe(testContract.userContact.userId);
});
- it('Should return 200: Should return empty array for unknown service', async function () {
+ it('returns 404 when contract is not found', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .query({ limit: 20 })
- .send({ filters: { services: ['non-existent-service-xyz'] } })
- .expect(200);
+ .get(`${baseUrl}/organizations/${testOrganization.id}/contracts/non-existent-userId`)
+ .set('x-api-key', ownerUser.apiKey);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- expect(response.body.length).toBe(0);
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
});
- it('Should return 200: Should return empty array for known service but non-matching version', async function () {
- const created = await createRandomContract(app);
- const serviceName = Object.keys(created.contractedServices)[0];
- const wrongVersion = '0_0_0_nonexistent';
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).get(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts/some-userId`
+ );
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
+
+ describe('PUT /contracts/:userId', function () {
+ it('returns 200 and novates the contract', async function () {
+ const newService = await createTestService(testOrganization.id, `new-service-${Date.now()}`);
+ const pricingVersion = newService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(newService.name, pricingVersion, testOrganization.id!, app);
+
+ const novationData = {
+ contractedServices: { [newService.name.toLowerCase()]: newService.activePricings.keys().next().value! },
+ subscriptionPlans: { [newService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ };
const response = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .query({ limit: 20 })
- .send({ filters: { services: { [serviceName]: [wrongVersion] } } })
- .expect(200);
+ .put(`${baseUrl}/contracts/${testContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey)
+ .send(novationData);
- expect(response.body).toBeDefined();
- expect(Array.isArray(response.body)).toBeTruthy();
- // Should be empty because version doesn't match
- expect(response.body.length).toBe(0);
+ expect(response.status).toBe(200);
+ expect(response.body.contractedServices).toHaveProperty(newService.name.toLowerCase());
+
+ await deleteTestService(newService.name, testOrganization.id!);
});
- });
- describe('POST /contracts', function () {
- it('Should return 201 and the created contract', async function () {
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ it('returns 200 and contractedServices keys in lowercase even with uppercase service name', async function () {
+ const upperCaseServiceName = 'UPPERCASE-SERVICE';
+ const newService = await createTestService(testOrganization.id, upperCaseServiceName);
+ const pricingVersion = newService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(newService.name, pricingVersion, testOrganization.id!, app);
+
+ const novationData = {
+ contractedServices: { [newService.name]: newService.activePricings.keys().next().value! },
+ subscriptionPlans: { [newService.name]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ };
+
const response = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ .put(`${baseUrl}/contracts/${testContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey)
+ .send(novationData);
- expect(response.status).toBe(201);
- expect(response.body).toBeDefined();
- expect(response.body.userContact.userId).toBe(contractToCreate.userContact.userId);
- expect(response.body).toHaveProperty('billingPeriod');
- expect(response.body).toHaveProperty('usageLevels');
- expect(response.body).toHaveProperty('contractedServices');
- expect(response.body).toHaveProperty('subscriptionPlans');
- expect(response.body).toHaveProperty('subscriptionAddOns');
- expect(response.body).toHaveProperty('history');
- expect(new Date(response.body.billingPeriod.endDate)).toEqual(
- addDays(response.body.billingPeriod.startDate, response.body.billingPeriod.renewalDays)
- );
+ expect(response.status).toBe(200);
+
+ // Verify all keys are lowercase
+ const contractedServicesKeys = Object.keys(response.body.contractedServices);
+ const subscriptionPlansKeys = Object.keys(response.body.subscriptionPlans);
+ const subscriptionAddOnsKeys = Object.keys(response.body.subscriptionAddOns);
+
+ expect(contractedServicesKeys.every(key => key === key.toLowerCase())).toBe(true);
+ expect(subscriptionPlansKeys.every(key => key === key.toLowerCase())).toBe(true);
+ expect(subscriptionAddOnsKeys.every(key => key === key.toLowerCase())).toBe(true);
+
+ await deleteTestService(newService.name, testOrganization.id!);
});
- it('Should return 422 when userContact.userId is an empty string', async function () {
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ it('returns 404 when contract is not found', async function () {
+ const novationData = {
+ contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/non-existent-userId`)
+ .set('x-api-key', testOrgApiKey)
+ .send(novationData);
- // Force empty userId
- contractToCreate.userContact.userId = '';
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
+ });
+ it('returns 401 when API key is missing', async function () {
const response = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ .put(`${baseUrl}/contracts/some-userId`)
+ .send({});
- expect(response.status).toBe(422);
- expect(response.body).toBeDefined();
- expect(response.body.error).toBeDefined();
- // Validation message should mention userContact.userId or cannot be empty
- expect(response.body.error.toLowerCase()).toContain('usercontact.userid');
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
});
+ });
- it('Should return 400 given a contract with unexistent service', async function () {
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ describe('PUT /organizations/:organizationId/contracts/:userId', function () {
+ it('returns 200 and novates the contract with user API key', async function () {
+ const newService = await createTestService(testOrganization.id, `new-service-${Date.now()}`);
+ const pricingVersion = newService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(newService.name, pricingVersion, testOrganization.id!, app);
- contractToCreate.contractedServices['unexistent-service'] = '1.0.0';
+ const novationData = {
+ contractedServices: { [newService.name]: newService.activePricings.keys().next().value! },
+ subscriptionPlans: { [newService.name]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ };
const response = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ .put(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts/${testContract.userContact.userId}`
+ )
+ .set('x-api-key', ownerUser.apiKey)
+ .send(novationData);
- expect(response.status).toBe(400);
- expect(response.body).toBeDefined();
- expect(response.body.error).toBe('Invalid contract: Services not found: unexistent-service');
+ expect(response.status).toBe(200);
+ expect(response.body.contractedServices).toHaveProperty(newService.name.toLowerCase());
+
+ await deleteTestService(newService.name, testOrganization.id!);
});
- it('Should return 400 given a contract with existent service, but invalid version', async function () {
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/contracts/some-userId`)
+ .send({});
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
- const existingService = Object.keys(contractToCreate.contractedServices)[0];
- contractToCreate.contractedServices[existingService] = 'invalid-version';
+ it('returns 403 when non-ADMIN user tries to modify contract in another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(foreignOrganization.id!, [foreignService], app);
+ trackContractForCleanup(foreignContract);
const response = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ .put(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({
+ contractedServices: { [foreignService.name.toLowerCase()]: foreignService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
- expect(response.status).toBe(400);
- expect(response.body).toBeDefined();
- expect(response.body.error).toBe(
- `Invalid contract: Pricing version invalid-version for service ${existingService} not found`
- );
+ expect(response.status).toBe(403);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
- it('Should return 400 given a contract with a non-existent plan for a contracted service', async function () {
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ it('returns 200 when ADMIN user modifies contract in another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(foreignOrganization.id!, [foreignService], app);
+ trackContractForCleanup(foreignContract);
+
+ const newService = await createTestService(foreignOrganization.id, `admin-mod-service-${Date.now()}`);
+ const pricingVersion = newService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(
+ newService.name,
+ pricingVersion,
+ foreignOrganization.id!,
+ app
+ );
- const serviceName = Object.keys(contractToCreate.contractedServices)[0];
- // Set an invalid plan name
- contractToCreate.subscriptionPlans[serviceName] = 'NON_EXISTENT_PLAN';
+ const novationData = {
+ contractedServices: { [newService.name.toLowerCase()]: pricingVersion },
+ subscriptionPlans: { [newService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ };
const response = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ .put(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', adminUser.apiKey)
+ .send(novationData);
- expect(response.status).toBe(400);
- expect(response.body).toBeDefined();
- expect(response.body.error).toBeDefined();
- expect(String(response.body.error)).toContain('Invalid subscription');
+ expect(response.status).toBe(200);
+ expect(response.body.contractedServices).toHaveProperty(newService.name.toLowerCase());
+
+ await deleteTestService(newService.name, foreignOrganization.id!);
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
+ });
- it('Should return 400 given a contract with a non-existent add-on for a contracted service', async function () {
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ describe('PUT /contracts', function () {
+ it('returns 200 and novates all contracts in a group from the requesting organization only', async function () {
+ const sharedGroupId = `bulk-global-group-${Date.now()}`;
+ const unaffectedGroupId = `bulk-global-unaffected-${Date.now()}`;
- const serviceName = Object.keys(contractToCreate.contractedServices)[0];
- // Inject an invalid add-on name
- contractToCreate.subscriptionAddOns[serviceName] = { non_existent_addon: 1 };
+ const targetContract = await createTestContract(testOrganization.id!, [testService], app, sharedGroupId);
+ trackContractForCleanup(targetContract);
+
+ const unaffectedContract = await createTestContract(
+ testOrganization.id!,
+ [testService],
+ app,
+ unaffectedGroupId
+ );
+ trackContractForCleanup(unaffectedContract);
+
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(foreignOrganization.id!, { key: foreignOrgApiKey, scope: 'ALL' });
+
+ const foreignContract = await createTestContract(
+ foreignOrganization.id!,
+ [foreignService],
+ app,
+ sharedGroupId
+ );
+ trackContractForCleanup(foreignContract);
+
+ const novationService = await createTestService(testOrganization.id, `bulk-global-service-${Date.now()}`);
+ const pricingVersion = novationService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(
+ novationService.name,
+ pricingVersion,
+ testOrganization.id!,
+ app
+ );
+
+ const novationData = {
+ contractedServices: { [novationService.name.toLowerCase()]: pricingVersion },
+ subscriptionPlans: { [novationService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ };
const response = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ .put(`${baseUrl}/contracts?groupId=${sharedGroupId}`)
+ .set('x-api-key', testOrgApiKey)
+ .send(novationData);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === sharedGroupId)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === testOrganization.id)).toBe(true);
+ expect(
+ response.body.every(
+ (c: LeanContract) => c.contractedServices[novationService.name.toLowerCase()] === pricingVersion
+ )
+ ).toBe(true);
+
+ const unaffectedResponse = await request(app)
+ .get(`${baseUrl}/contracts/${unaffectedContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey);
+ expect(unaffectedResponse.status).toBe(200);
+ expect(unaffectedResponse.body.contractedServices[novationService.name.toLowerCase()]).toBeUndefined();
+
+ const foreignContractResponse = await request(app)
+ .get(`${baseUrl}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', foreignOrgApiKey);
+ expect(foreignContractResponse.status).toBe(200);
+ expect(foreignContractResponse.body.organizationId).toBe(foreignOrganization.id);
+ expect(
+ foreignContractResponse.body.contractedServices[novationService.name.toLowerCase()]
+ ).toBeUndefined();
+
+ await deleteTestService(novationService.name, testOrganization.id!);
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
+ });
+
+ it('returns 400 when groupId query parameter is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey)
+ .send({
+ contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
expect(response.status).toBe(400);
- expect(response.body).toBeDefined();
- expect(response.body.error).toBeDefined();
- expect(String(response.body.error)).toContain('Invalid subscription');
+ expect(response.body.error).toContain('Missing groupId');
});
- it('Should return 400 when creating a contract for a userId that already has a contract', async function () {
- // Create initial contract
- const { contract: contractToCreate } = await generateContractAndService(undefined, app);
+ it('returns 404 when no contracts are found for groupId in the requesting organization', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts?groupId=group-does-not-exist`)
+ .set('x-api-key', testOrgApiKey)
+ .send({
+ contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
- const firstResponse = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ expect(response.status).toBe(404);
+ expect(response.body.error.toLowerCase()).toContain('no contracts found');
+ });
- expect(firstResponse.status).toBe(201);
+ it('returns 403 when non-ADMIN user API key is used', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts?groupId=any-group`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({
+ contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
- // Try to create another contract with the same userId
- const secondResponse = await request(app)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send(contractToCreate);
+ expect(response.status).toBe(403);
+ });
+
+ it('returns 422 when payload is invalid', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts?groupId=any-group`)
+ .set('x-api-key', testOrgApiKey)
+ .send({ contractedServices: { [testService.name.toLowerCase()]: '1.0.0' } });
- expect(secondResponse.status).toBe(400);
- expect(secondResponse.body).toBeDefined();
- expect(secondResponse.body.error).toBeDefined();
- expect(secondResponse.body.error.toLowerCase()).toContain('already exists');
+ expect(response.status).toBe(422);
});
- });
- describe('GET /contracts/:userId', function () {
- it('Should return 200 and the contract for the given userId', async function () {
+ it('returns 200 when ADMIN user novates contracts by groupId globally', async function () {
+ const adminGroupId = `admin-global-${Date.now()}`;
+ const adminContract = await createTestContract(testOrganization.id!, [testService], app, adminGroupId);
+ trackContractForCleanup(adminContract);
+
+ const adminService = await createTestService(testOrganization.id, `admin-global-service-${Date.now()}`);
+ const pricingVersion = adminService.activePricings.keys().next().value!;
+ const pricingDetails = await getPricingFromService(
+ adminService.name,
+ pricingVersion,
+ testOrganization.id!,
+ app
+ );
+
const response = await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .put(`${baseUrl}/contracts?groupId=${adminGroupId}`)
+ .set('x-api-key', adminUser.apiKey)
+ .send({
+ contractedServices: { [adminService.name.toLowerCase()]: pricingVersion },
+ subscriptionPlans: { [adminService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] },
+ subscriptionAddOns: {},
+ });
- const contract: TestContract = response.body;
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === adminGroupId)).toBe(true);
- expect(contract).toBeDefined();
- expect(contract.userContact.userId).toBe(testUserId);
- expect(contract).toHaveProperty('billingPeriod');
- expect(contract).toHaveProperty('usageLevels');
- expect(contract).toHaveProperty('contractedServices');
- expect(contract).toHaveProperty('subscriptionPlans');
- expect(contract).toHaveProperty('subscriptionAddOns');
- expect(contract).toHaveProperty('history');
- expect(Object.values(Object.values(contract.usageLevels)[0])[0].consumed).toBeTruthy();
+ await deleteTestService(adminService.name, testOrganization.id!);
});
- it('Should return 404 if the contract is not found', async function () {
+ it('returns 401 when API key is missing', async function () {
const response = await request(app)
- .get(`${baseUrl}/contracts/invalid-user-id`)
- .set('x-api-key', adminApiKey)
- .expect(404);
+ .put(`${baseUrl}/contracts?groupId=any-group`)
+ .send({
+ contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ subscriptionPlans: {},
+ subscriptionAddOns: {},
+ });
- expect(response.body).toBeDefined();
- expect(response.body.error).toContain('not found');
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
});
});
- describe('PUT /contracts/:userId', function () {
- it('Should return 200 and the novated contract', async function () {
- const newContract = await createRandomContract(app);
- const newContractFullData = await getContractByUserId(newContract.userContact.userId, app);
+ describe('PUT /contracts/billingPeriod', function () {
+ it('returns 200 and updates billing period by groupId for contracts in the request organization only', async function () {
+ const sharedGroupId = `billing-group-${Date.now()}`;
+ const unaffectedGroupId = `billing-unaffected-${Date.now()}`;
- const novation = await generateNovation();
+ const targetContract = await createTestContract(testOrganization.id!, [testService], app, sharedGroupId);
+ trackContractForCleanup(targetContract);
+
+ const unaffectedContract = await createTestContract(
+ testOrganization.id!,
+ [testService],
+ app,
+ unaffectedGroupId
+ );
+ trackContractForCleanup(unaffectedContract);
+
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(foreignOrganization.id!, { key: foreignOrgApiKey, scope: 'ALL' });
+
+ const foreignContract = await createTestContract(
+ foreignOrganization.id!,
+ [foreignService],
+ app,
+ sharedGroupId
+ );
+ trackContractForCleanup(foreignContract);
+
+ const billingData = {
+ autoRenew: !targetContract.billingPeriod.autoRenew,
+ renewalDays: targetContract.billingPeriod.renewalDays === 30 ? 365 : 30,
+ };
+
+ const foreignContractBefore = await request(app)
+ .get(`${baseUrl}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', foreignOrgApiKey);
+
+ expect(foreignContractBefore.status).toBe(200);
const response = await request(app)
- .put(`${baseUrl}/contracts/${newContract.userContact.userId}`)
- .set('x-api-key', adminApiKey)
- .send(novation)
- .expect(200);
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=${sharedGroupId}`)
+ .set('x-api-key', testOrgApiKey)
+ .send(billingData);
- expect(response.body).toBeDefined();
- expect(response.body.userContact.userId).toBe(newContract.userContact.userId);
- expect(response.body).toHaveProperty('billingPeriod');
- expect(response.body).toHaveProperty('usageLevels');
- expect(response.body).toHaveProperty('contractedServices');
- expect(response.body).toHaveProperty('subscriptionPlans');
- expect(response.body).toHaveProperty('subscriptionAddOns');
- expect(response.body).toHaveProperty('history');
- expect(response.body.history.length).toBe(1);
- expect(newContractFullData.billingPeriod.startDate).not.toEqual(
- response.body.billingPeriod.startDate
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === sharedGroupId)).toBe(true);
+ expect(response.body.every((c: LeanContract) => c.organizationId === testOrganization.id)).toBe(true);
+ expect(
+ response.body.every((c: LeanContract) => c.billingPeriod.autoRenew === billingData.autoRenew)
+ ).toBe(true);
+ expect(
+ response.body.every((c: LeanContract) => c.billingPeriod.renewalDays === billingData.renewalDays)
+ ).toBe(true);
+
+ const unaffectedResponse = await request(app)
+ .get(`${baseUrl}/contracts/${unaffectedContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey);
+ expect(unaffectedResponse.status).toBe(200);
+ expect(unaffectedResponse.body.groupId).toBe(unaffectedGroupId);
+
+ const foreignContractResponse = await request(app)
+ .get(`${baseUrl}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', foreignOrgApiKey);
+ expect(foreignContractResponse.status).toBe(200);
+ expect(foreignContractResponse.body.organizationId).toBe(foreignOrganization.id);
+ expect(foreignContractResponse.body.billingPeriod.autoRenew).toBe(
+ foreignContractBefore.body.billingPeriod.autoRenew
);
- expect(new Date(response.body.billingPeriod.endDate)).toEqual(
- addDays(response.body.billingPeriod.startDate, response.body.billingPeriod.renewalDays)
+ expect(foreignContractResponse.body.billingPeriod.renewalDays).toBe(
+ foreignContractBefore.body.billingPeriod.renewalDays
);
+
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
});
- });
- describe('DELETE /contracts/:userId', function () {
- it('Should return 204', async function () {
- const newContract = await createRandomContract(app);
+ it('returns 400 when groupId query parameter is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/billingPeriod`)
+ .set('x-api-key', testOrgApiKey)
+ .send({ autoRenew: true, renewalDays: 30 });
- await request(app)
- .delete(`${baseUrl}/contracts/${newContract.userContact.userId}`)
- .set('x-api-key', adminApiKey)
- .expect(204);
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Missing groupId');
});
- it('Should return 404 with invalid userId', async function () {
+
+ it('returns 404 when no contracts are found for groupId in the requesting organization', async function () {
const response = await request(app)
- .delete(`${baseUrl}/contracts/invalid-user-id`)
- .set('x-api-key', adminApiKey)
- .expect(404);
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=group-does-not-exist`)
+ .set('x-api-key', testOrgApiKey)
+ .send({ autoRenew: true, renewalDays: 30 });
- expect(response.body).toBeDefined();
+ expect(response.status).toBe(404);
expect(response.body.error.toLowerCase()).toContain('not found');
});
- });
-
- describe('PUT /contracts/:userId/usageLevels', function () {
- it('Should return 200 and the novated contract: Given usage level increment', async function () {
- const newContract: TestContract = await createRandomContract(app);
- const serviceKey = Object.keys(newContract.usageLevels)[0];
- const usageLevelKey = Object.keys(newContract.usageLevels[serviceKey])[0];
- const usageLevel = newContract.usageLevels[serviceKey][usageLevelKey];
+ it('returns 403 when non-ADMIN user API key is used', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=any-group`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ autoRenew: true, renewalDays: 30 });
- expect(usageLevel.consumed).toBe(0);
+ expect(response.status).toBe(403);
+ });
+ it('returns 422 when payload is invalid', async function () {
const response = await request(app)
- .put(`${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels`)
- .set('x-api-key', adminApiKey)
- .send({
- [serviceKey]: {
- [usageLevelKey]: 5,
- },
- });
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=any-group`)
+ .set('x-api-key', testOrgApiKey)
+ .send({ autoRenew: 'invalid-boolean' });
- expect(response.status).toBe(200);
+ expect(response.status).toBe(422);
+ });
+
+ it('returns 200 when ADMIN user updates billing period by groupId globally', async function () {
+ const adminBillingGroupId = `admin-billing-${Date.now()}`;
+ const adminBillingContract = await createTestContract(
+ testOrganization.id!,
+ [testService],
+ app,
+ adminBillingGroupId
+ );
+ trackContractForCleanup(adminBillingContract);
- const updatedContract: TestContract = response.body;
+ const billingData = {
+ autoRenew: !adminBillingContract.billingPeriod.autoRenew,
+ renewalDays: adminBillingContract.billingPeriod.renewalDays === 30 ? 365 : 30,
+ };
- expect(updatedContract).toBeDefined();
- expect(updatedContract.userContact.userId).toBe(newContract.userContact.userId);
- expect(updatedContract.usageLevels[serviceKey][usageLevelKey].consumed).toBe(5);
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=${adminBillingGroupId}`)
+ .set('x-api-key', adminUser.apiKey)
+ .send(billingData);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === adminBillingGroupId)).toBe(true);
+ expect(
+ response.body.every((c: LeanContract) => c.billingPeriod.autoRenew === billingData.autoRenew)
+ ).toBe(true);
});
- it('Should return 200 and the novated contract: Given reset only', async function () {
- let newContract: TestContract = await createRandomContract(app);
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=any-group`)
+ .send({ autoRenew: true, renewalDays: 30 });
- Object.values(newContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBe(0);
- });
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
- newContract = await incrementAllUsageLevel(
- newContract.userContact.userId,
- newContract.usageLevels,
- app
+ it('returns 200 when ADMIN user updates billing period by groupId globally', async function () {
+ const adminBillingGroupId = `admin-billing-${Date.now()}`;
+ const adminBillingContract = await createTestContract(
+ testOrganization.id!,
+ [testService],
+ app,
+ adminBillingGroupId
);
+ trackContractForCleanup(adminBillingContract);
- Object.values(newContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBeGreaterThan(0);
- });
+ const billingData = {
+ autoRenew: !adminBillingContract.billingPeriod.autoRenew,
+ renewalDays: adminBillingContract.billingPeriod.renewalDays === 30 ? 365 : 30,
+ };
const response = await request(app)
- .put(`${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels?reset=true`)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .put(`${baseUrl}/contracts/billingPeriod?groupId=${adminBillingGroupId}`)
+ .set('x-api-key', adminUser.apiKey)
+ .send(billingData);
- const updatedContract: TestContract = response.body;
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+ expect(response.body.every((c: LeanContract) => c.groupId === adminBillingGroupId)).toBe(true);
+ expect(
+ response.body.every((c: LeanContract) => c.billingPeriod.autoRenew === billingData.autoRenew)
+ ).toBe(true);
+ });
+ });
- expect(updatedContract).toBeDefined();
- expect(updatedContract.userContact.userId).toBe(newContract.userContact.userId);
+ describe('DELETE /contracts/:userId', function () {
+ it('returns 204 when deleting an existing contract', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/${testContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey);
- // All RENEWABLE limits are reset to 0
- Object.values(updatedContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- if (ul.resetTimeStamp) {
- expect(ul.consumed).toBe(0);
- }
- });
+ expect(response.status).toBe(204);
- // All NON_RENEWABLE limits are not reset
- Object.values(updatedContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- if (!ul.resetTimeStamp) {
- expect(ul.consumed).toBeGreaterThan(0);
- }
- });
+ // Verify deletion
+ const getResponse = await request(app)
+ .get(`${baseUrl}/contracts/${testContract.userContact.userId}`)
+ .set('x-api-key', testOrgApiKey);
+ expect(getResponse.status).toBe(404);
});
- it('Should return 200 and the novated contract: Given reset and disabled renewableOnly', async function () {
- let newContract: TestContract = await createRandomContract(app);
+ it('returns 404 when deleting a non-existent contract', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/non-existent-userId`)
+ .set('x-api-key', testOrgApiKey);
- Object.values(newContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBe(0);
- });
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
+ });
- newContract = await incrementAllUsageLevel(
- newContract.userContact.userId,
- newContract.usageLevels,
- app
- );
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).delete(`${baseUrl}/contracts/some-userId`);
- Object.values(newContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBeGreaterThan(0);
- });
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
+ describe('DELETE /organizations/:organizationId/contracts/:userId', function () {
+ it('returns 204 when deleting an existing contract with user API key', async function () {
const response = await request(app)
- .put(
- `${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels?reset=true&renewableOnly=false`
+ .delete(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts/${testContract.userContact.userId}`
)
- .set('x-api-key', adminApiKey)
- .expect(200);
+ .set('x-api-key', ownerUser.apiKey);
- const updatedContract: TestContract = response.body;
+ expect(response.status).toBe(204);
+ });
- expect(updatedContract).toBeDefined();
- expect(updatedContract.userContact.userId).toBe(newContract.userContact.userId);
+ it('returns 404 when deleting a non-existent contract', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/contracts/non-existent-userId`)
+ .set('x-api-key', ownerUser.apiKey);
- // All usage levels are reset to 0
- Object.values(updatedContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBe(0);
- });
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
+ });
+
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).delete(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts/some-userId`
+ );
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
});
- it('Should return 200 and the novated contract: Given usageLimit', async function () {
- let newContract: TestContract = await createRandomContract(app);
+ it('returns 403 when non-ADMIN user tries to delete a contract in another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(foreignOrganization.id!, [foreignService], app);
+ trackContractForCleanup(foreignContract);
- Object.values(newContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBe(0);
- });
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', ownerUser.apiKey);
- newContract = await incrementAllUsageLevel(
- newContract.userContact.userId,
- newContract.usageLevels,
- app
- );
+ expect(response.status).toBe(403);
- Object.values(newContract.usageLevels)
- .map((s: Record) => Object.values(s))
- .flat()
- .forEach((ul: UsageLevel) => {
- expect(ul.consumed).toBeGreaterThan(0);
- });
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
+ });
- const serviceKey = Object.keys(newContract.usageLevels)[0];
- const sampleUsageLimitKey = Object.keys(newContract.usageLevels[serviceKey])[0];
+ it('returns 204 when ADMIN user deletes a contract in another organization', async function () {
+ const foreignOrgOwner = await createTestUser('USER');
+ const foreignOrganization = await createTestOrganization(foreignOrgOwner.username);
+ const foreignService = await createTestService(foreignOrganization.id);
+ const foreignContract = await createTestContract(foreignOrganization.id!, [foreignService], app);
const response = await request(app)
- .put(
- `${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels?usageLimit=${sampleUsageLimitKey}`
- )
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', adminUser.apiKey);
- expect(response.status).toBe(200);
+ expect(response.status).toBe(204);
- const updatedContract: TestContract = response.body;
+ const getResponse = await request(app)
+ .get(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`)
+ .set('x-api-key', adminUser.apiKey);
+ expect(getResponse.status).toBe(404);
- expect(updatedContract).toBeDefined();
- expect(updatedContract.userContact.userId).toBe(newContract.userContact.userId);
+ await deleteTestService(foreignService.name, foreignOrganization.id!);
+ await deleteTestOrganization(foreignOrganization.id!);
+ await deleteTestUser(foreignOrgOwner.username);
+ });
+ });
- // Check if all usage levels are greater than 0, except the one specified in the query
- Object.entries(updatedContract.usageLevels).forEach(([serviceKey, usageLimits]) => {
- Object.entries(usageLimits).forEach(([usageLimitKey, usageLevel]) => {
- if (usageLimitKey === sampleUsageLimitKey) {
- expect(usageLevel.consumed).toBe(0);
- } else {
- expect(usageLevel.consumed).toBeGreaterThan(0);
- }
- });
- });
+ describe('DELETE /contracts', function () {
+ it('returns 204 when deleting all contracts with org API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrgApiKey);
+
+ expect(response.status).toBe(204);
});
- it('Should return 404: Given reset and usageLimit', async function () {
- const newContract: TestContract = await createRandomContract(app);
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).delete(`${baseUrl}/contracts`);
- await request(app)
- .put(
- `${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels?reset=true&usageLimit=test`
- )
- .set('x-api-key', adminApiKey)
- .expect(400);
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId/contracts', function () {
+ it('returns 204 when deleting all contracts for an organization', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey);
+
+ expect(response.status).toBe(204);
+ });
+
+ it('returns 401 when API key is missing', async function () {
+ const response = await request(app).delete(
+ `${baseUrl}/organizations/${testOrganization.id}/contracts`
+ );
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
});
+ });
- it('Should return 404: Given invalid usageLimit', async function () {
- const newContract: TestContract = await createRandomContract(app);
+ describe('PUT /contracts/:userId/usageLevels', function () {
+ it('returns 200 and increments usage levels', async function () {
+ const serviceLowercase = testService.name.toLowerCase();
+ const usageLimitName = testContract.usageLevels && testContract.usageLevels[serviceLowercase]
+ ? Object.keys(testContract.usageLevels[serviceLowercase])[0]
+ : 'defaultLimit';
+
+ const incrementData = {
+ [testService.name]: {
+ [usageLimitName]: 10,
+ },
+ };
- await request(app)
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/${testContract.userContact.userId}/usageLevels`)
+ .set('x-api-key', testOrgApiKey)
+ .send(incrementData);
+
+ expect(response.status).toBe(200);
+ if (response.body.usageLevels && response.body.usageLevels[serviceLowercase]) {
+ expect(response.body.usageLevels[serviceLowercase][usageLimitName].consumed).toBeGreaterThanOrEqual(10);
+ }
+ });
+
+ it('returns 200 and resets usage levels with reset=true', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/${testContract.userContact.userId}/usageLevels?reset=true`)
+ .set('x-api-key', testOrgApiKey)
+ .send({});
+
+ expect(response.status).toBe(200);
+ });
+
+ it('returns 400 when both reset and usageLimit are provided', async function () {
+ const response = await request(app)
.put(
- `${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels?usageLimit=invalid-usage-limit`
+ `${baseUrl}/contracts/${testContract.userContact.userId}/usageLevels?reset=true&usageLimit=someLimit`
)
- .set('x-api-key', adminApiKey)
- .expect(404);
+ .set('x-api-key', testOrgApiKey)
+ .send({});
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Invalid query');
});
- it('Should return 422: Given invalid body', async function () {
- const newContract: TestContract = await createRandomContract(app);
+ it('returns 404 when contract is not found', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/non-existent-userId/usageLevels`)
+ .set('x-api-key', testOrgApiKey)
+ .send({});
- await request(app)
- .put(`${baseUrl}/contracts/${newContract.userContact.userId}/usageLevels`)
- .set('x-api-key', adminApiKey)
- .send({
- test: 'invalid object',
- })
- .expect(422);
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
});
});
describe('PUT /contracts/:userId/userContact', function () {
- it('Should return 200 and the updated contract', async function () {
- const newContract: TestContract = await createRandomContract(app);
-
- const newUserContactFields = {
- username: 'newUsername',
- firstName: 'newFirstName',
- lastName: 'newLastName',
+ it('returns 200 and updates user contact information', async function () {
+ const newContactData = {
+ firstName: 'NewFirstName',
+ lastName: 'NewLastName',
+ email: 'newemail@example.com',
};
const response = await request(app)
- .put(`${baseUrl}/contracts/${newContract.userContact.userId}/userContact`)
- .set('x-api-key', adminApiKey)
- .send(newUserContactFields)
- .expect(200);
- const updatedContract: TestContract = response.body;
+ .put(`${baseUrl}/contracts/${testContract.userContact.userId}/userContact`)
+ .set('x-api-key', testOrgApiKey)
+ .send(newContactData);
- expect(updatedContract).toBeDefined();
- expect(updatedContract.userContact.username).toBe(newUserContactFields.username);
- expect(updatedContract.userContact.firstName).toBe(newUserContactFields.firstName);
- expect(updatedContract.userContact.lastName).toBe(newUserContactFields.lastName);
- expect(updatedContract.userContact.email).toBe(newContract.userContact.email);
- expect(updatedContract.userContact.phone).toBe(newContract.userContact.phone);
+ expect(response.status).toBe(200);
+ expect(response.body.userContact.firstName).toBe('NewFirstName');
+ expect(response.body.userContact.lastName).toBe('NewLastName');
+ expect(response.body.userContact.email).toBe('newemail@example.com');
+ });
+
+ it('returns 404 when contract is not found', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/non-existent-userId/userContact`)
+ .set('x-api-key', testOrgApiKey)
+ .send({ firstName: 'Test' });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
});
});
describe('PUT /contracts/:userId/billingPeriod', function () {
- it('Should return 200 and the updated contract', async function () {
- const newContract: TestContract = await createRandomContract(app);
-
- const newBillingPeriodFields = {
- endDate: addDays(newContract.billingPeriod.endDate, 3),
+ it('returns 200 and updates billing period', async function () {
+ const newBillingPeriod = {
+ endDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
autoRenew: true,
- renewalDays: 30,
+ renewalDays: 365,
};
const response = await request(app)
- .put(`${baseUrl}/contracts/${newContract.userContact.userId}/billingPeriod`)
- .set('x-api-key', adminApiKey)
- .send(newBillingPeriodFields)
- .expect(200);
- const updatedContract: TestContract = response.body;
+ .put(`${baseUrl}/contracts/${testContract.userContact.userId}/billingPeriod`)
+ .set('x-api-key', testOrgApiKey)
+ .send(newBillingPeriod);
- expect(updatedContract).toBeDefined();
- expect(new Date(updatedContract.billingPeriod.endDate)).toEqual(
- addDays(newContract.billingPeriod.endDate, 3)
- );
- expect(updatedContract.billingPeriod.autoRenew).toBe(true);
- expect(updatedContract.billingPeriod.renewalDays).toBe(30);
+ expect(response.status).toBe(200);
+ expect(response.body.billingPeriod.autoRenew).toBe(true);
+ expect(response.body.billingPeriod.renewalDays).toBe(365);
});
- });
-
- describe('DELETE /contracts', function () {
- it('Should return 204 and delete all contracts', async function () {
- const servicesBefore = await getAllContracts(app);
- expect(servicesBefore.length).toBeGreaterThan(0);
- await request(app).delete(`${baseUrl}/contracts`).set('x-api-key', adminApiKey).expect(204);
+ it('returns 404 when contract is not found', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/non-existent-userId/billingPeriod`)
+ .set('x-api-key', testOrgApiKey)
+ .send({ autoRenew: true });
- const servicesAfter = await getAllContracts(app);
- expect(servicesAfter.length).toBe(0);
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('not found');
});
});
});
diff --git a/api/src/test/data/pricings/petclinic-2025.yml b/api/src/test/data/pricings/petclinic-2025.yml
index 38c460d..991375b 100644
--- a/api/src/test/data/pricings/petclinic-2025.yml
+++ b/api/src/test/data/pricings/petclinic-2025.yml
@@ -15,6 +15,11 @@ features:
expression: pricingContext['features']['visits'] && subscriptionContext['maxVisits'] < pricingContext['usageLimits']['maxVisits']
serverExpression: pricingContext['features']['visits'] && subscriptionContext['maxVisits'] <= pricingContext['usageLimits']['maxVisits']
type: DOMAIN
+ multiDependentFeature:
+ valueType: BOOLEAN
+ defaultValue: true
+ expression: pricingContext['features']['multiDependentFeature'] && subscriptionContext['maxVisits'] < pricingContext['usageLimits']['maxVisits'] && subscriptionContext['maxPets'] < pricingContext['usageLimits']['maxPets']
+ type: DOMAIN
calendar:
valueType: BOOLEAN
defaultValue: false
@@ -74,6 +79,7 @@ usageLimits:
trackable: true
linkedFeatures:
- pets
+ - multiDependentFeature
maxVisits:
valueType: NUMERIC
defaultValue: 1
@@ -84,6 +90,7 @@ usageLimits:
unit: MONTH
linkedFeatures:
- visits
+ - multiDependentFeature
calendarEventsCreationLimit:
valueType: NUMERIC
defaultValue: 5
diff --git a/api/src/test/events.test.ts b/api/src/test/events.test.ts
index a03f0e7..4b1ecf2 100644
--- a/api/src/test/events.test.ts
+++ b/api/src/test/events.test.ts
@@ -4,42 +4,107 @@ import request from 'supertest';
import { baseUrl, getApp, shutdownApp } from './utils/testApp';
import { Server } from 'http';
import { cleanupAuthResources, getTestAdminApiKey, getTestAdminUser } from './utils/auth';
-import { getRandomPricingFile } from './utils/services/service';
+import { addArchivedPricingToService, addPricingToService, createTestService, deleteTestService, getRandomPricingFile } from './utils/services/serviceTestUtils';
import { v4 as uuidv4 } from 'uuid';
+import { LeanOrganization } from '../main/types/models/Organization';
+import { LeanUser } from '../main/types/models/User';
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
+import { addApiKeyToOrganization, createTestOrganization, deleteTestOrganization } from './utils/organization/organizationTestUtils';
+import { LeanService } from '../main/types/models/Service';
+import { generateOrganizationApiKey } from '../main/utils/users/helpers';
+import { getFirstPlanFromPricing } from './utils/regex';
+
+// Helper sencillo para esperar mensajes (evita el callback hell)
+const waitForPricingEvent = (socket: Socket, eventCode: string) => {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error(`Timeout waiting for event code: ${eventCode}`));
+ }, 4000); // 4s timeout interno
+
+ const listener = (data: any) => {
+ if (data && data.code === eventCode) {
+ clearTimeout(timeout);
+ socket.off('message', listener); // Limpieza importante
+ resolve(data);
+ }
+ };
+
+ socket.on('message', listener);
+ });
+};
describe('Events API Test Suite', function () {
let app: Server;
- let adminApiKey: string;
let socketClient: Socket;
let pricingNamespace: Socket;
+ let testOwner: LeanUser;
+ let testAdmin: LeanUser;
+ let testOrganization: LeanOrganization;
+ let testOrgApiKey: string;
+ let testService: LeanService;
beforeAll(async function () {
app = await getApp();
- // Get admin user and api key for testing
- await getTestAdminUser();
- adminApiKey = await getTestAdminApiKey();
-
- // Create a socket.io client for testing
socketClient = io(`ws://localhost:3000`, {
path: '/events',
- autoConnect: false,
+ autoConnect: false,
transports: ['websocket'],
});
-
- // Create a namespace client for pricing events
pricingNamespace = socketClient.io.socket('/pricings');
});
- beforeEach(() => {
- pricingNamespace.connect();
- });
+ beforeEach(async () => {
+ // 1. Iniciamos conexión
+ pricingNamespace.connect();
+
+ // 2. 🛑 ESPERAMOS explícitamente a que conecte antes de soltar el test
+ if (!pricingNamespace.connected) {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('Connection timeout')), 1000);
+ pricingNamespace.once('connect', () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ pricingNamespace.once('connect_error', (err) => {
+ clearTimeout(timer);
+ reject(err);
+ });
+ });
+ }
- afterEach(() => {
- if (pricingNamespace.connected) {
- pricingNamespace.disconnect();
- }
- pricingNamespace.removeAllListeners(); // 💡 MUY IMPORTANTE
- });
+ // 3. Crear datos
+ testOwner = await createTestUser("USER");
+ testAdmin = await createTestUser("ADMIN");
+ testOrganization = await createTestOrganization(testOwner.username);
+ testOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, { key: testOrgApiKey, scope: "ALL"});
+ testService = await createTestService(testOrganization.id);
+ });
+
+ afterEach(async () => {
+ pricingNamespace.removeAllListeners(); // 💡 MUY IMPORTANTE
+
+ if (pricingNamespace.connected) {
+ pricingNamespace.disconnect();
+ }
+
+ // Cleanup created users and organization
+ if (testService.id) {
+ await deleteTestService(testService.name, testOrganization.id!);
+ }
+
+ if (testOrganization.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+
+ if (testOwner.id) {
+ await deleteTestUser(testOwner.id);
+ }
+
+ if (testAdmin.id) {
+ await deleteTestUser(testAdmin.id);
+ }
+ });
afterAll(async function () {
// Ensure socket disconnection
@@ -54,26 +119,15 @@ describe('Events API Test Suite', function () {
describe('WebSocket Connection', function () {
it('Should connect to the WebSocket server successfully', async () => {
- await new Promise((resolve, reject) => {
- // Set up connection event handler before connecting
- pricingNamespace.on('connect', () => {
- expect(pricingNamespace.connected).toBe(true);
- resolve(true);
- });
-
- // Set up error handler
- pricingNamespace.on('connect_error', err => {
- reject(err);
- });
+ expect(pricingNamespace.connected).toBe(true);
});
});
- });
describe('Events API Endpoints', function () {
it('Should return status 200 when checking event service status', async function () {
const response = await request(app)
.get(`${baseUrl}/events/status`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrgApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -81,138 +135,58 @@ describe('Events API Test Suite', function () {
});
it('Should emit test event via API endpoint', async () => {
- await new Promise((resolve, reject) => {
- // Set up message event handler
- pricingNamespace.on('message', data => {
- try {
- expect(data).toBeDefined();
- expect(data.code).toEqual('PRICING_ARCHIVED');
- expect(data.details).toBeDefined();
- expect(data.details.serviceName).toEqual('test-service');
- expect(data.details.pricingVersion).toEqual('2025');
- resolve();
- } catch (error) {
- reject(error);
- }
- });
-
- // Wait for connection before sending test event
- pricingNamespace.on('connect', async () => {
- try {
- // Send test event via API
- await request(app)
- .post(`${baseUrl}/events/test-event`)
- .set('x-api-key', adminApiKey)
- .send({
- serviceName: 'test-service',
- pricingVersion: '2025',
- });
- } catch (error) {
- reject(error);
- }
- });
-
- // Handle connection errors
- pricingNamespace.on('connect_error', err => {
- reject(err);
- });
- });
+ // 1. Preparamos la "trampa" (listener) ANTES de disparar la acción
+ const eventPromise = waitForPricingEvent(pricingNamespace, 'PRICING_ARCHIVED');
+
+ // 2. Disparamos la acción (Ya estamos conectados gracias al beforeEach)
+ await request(app)
+ .post(`${baseUrl}/events/test-event`)
+ .set('x-api-key', testOrgApiKey)
+ .send({
+ serviceName: 'test-service',
+ pricingVersion: '2025',
+ })
+ .expect(200); // Supertest maneja errores http
+
+ // 3. Esperamos a que caiga la presa
+ const data = await eventPromise;
+
+ // 4. Aseveramos
+ expect(data.details.serviceName).toEqual('test-service');
+ expect(data.details.pricingVersion).toEqual('2025');
});
});
describe('Pricing Creation Events', function () {
it('Should emit event when uploading a new pricing file', async () => {
- await new Promise(async (resolve, reject) => {
- // Set up message event handler
- pricingNamespace.on('message', data => {
- try {
- expect(data).toBeDefined();
- expect(data.code).toEqual('PRICING_CREATED');
- expect(data.details).toBeDefined();
- expect(data.details.serviceName).toBeDefined();
- expect(data.details.pricingVersion).toBeDefined();
- resolve();
- } catch (error) {
- reject(error);
- }
- });
-
- // Wait for connection before uploading pricing
- pricingNamespace.on('connect', async () => {
- try {
- const pricingFile = await getRandomPricingFile(uuidv4());
-
- // Upload a pricing file which should trigger an event
- const response = await request(app)
- .post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', pricingFile);
-
- expect(response.status).toEqual(201);
- } catch (error) {
- reject(error);
- }
- });
-
- // Handle connection errors
- pricingNamespace.on('connect_error', err => {
- reject(err);
- });
- });
+ // 1. Preparar escucha
+ const eventPromise = waitForPricingEvent(pricingNamespace, 'PRICING_CREATED');
+
+ // 2. Acción
+ const pricingFilePath = await getRandomPricingFile(new Date().getTime().toString());
+ await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', testOrgApiKey)
+ .attach('pricing', pricingFilePath)
+ .expect(201);
+
+ // 3. Validación
+ const data = await eventPromise;
+ expect(data.details).toBeDefined();
});
it('Should emit event when changing pricing availability', async () => {
- await new Promise(async (resolve, reject) => {
- // This test requires an existing service with at least two pricings
-
- // Set up message event handler
- pricingNamespace.on('message', data => {
- const serviceName = 'Zoom'; // Assuming Zoom service exists with multiple pricings
- const pricingVersion = '2.0.0'; // Use a version we know exists
-
- try {
- expect(data).toBeDefined();
- expect(data.code).toEqual('PRICING_ARCHIVED');
- expect(data.details).toBeDefined();
- expect(data.details.serviceName).toEqual(serviceName);
- expect(data.details.pricingVersion).toEqual(pricingVersion);
- resolve();
- } catch (error) {
- reject(error);
- }
- });
-
- // Wait for connection before changing pricing availability
- pricingNamespace.on('connect', async () => {
- const serviceName = 'Zoom'; // Assuming Zoom service exists with multiple pricings
- const pricingVersion = '2.0.0'; // Use a version we know exists
-
- try {
- // First, check if service exists and has the required pricing
- const serviceResponse = await request(app)
- .get(`${baseUrl}/services/${serviceName}`)
- .set('x-api-key', adminApiKey);
-
- expect(serviceResponse.status).toEqual(200);
-
- // Archive the pricing (requires a fallback subscription)
- const respose = await request(app)
- .put(`${baseUrl}/services/${serviceName}/pricings/${pricingVersion}?availability=archived`)
- .set('x-api-key', adminApiKey)
- .send({
- subscriptionPlan: 'BASIC',
- subscriptionAddOns: {},
- });
- } catch (error) {
- reject(error);
- }
- });
-
- // Handle connection errors
- pricingNamespace.on('connect_error', err => {
- reject(err);
- });
- });
+ // 1. Preparar escucha
+ const eventPromise = waitForPricingEvent(pricingNamespace, 'PRICING_ARCHIVED');
+
+ // 2. Acción
+ const pricingVersion = "2.0.0";
+ await addArchivedPricingToService(testOrganization.id!, testService.name, pricingVersion);
+
+ // 3. Validación
+ const data = await eventPromise;
+ expect(data.details.serviceName).toEqual(testService.name);
+ expect(data.details.pricingVersion).toEqual(pricingVersion);
});
});
});
diff --git a/api/src/test/feature-evaluation.test.ts b/api/src/test/feature-evaluation.test.ts
index a91f461..543cc0e 100644
--- a/api/src/test/feature-evaluation.test.ts
+++ b/api/src/test/feature-evaluation.test.ts
@@ -1,7 +1,9 @@
import request from 'supertest';
+import path from 'path';
import { baseUrl, getApp, shutdownApp } from './utils/testApp';
import { Server } from 'http';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
+import container from '../main/config/container';
import { LeanFeature } from '../main/types/models/FeatureEvaluation';
import { LeanService } from '../main/types/models/Service';
import { v4 as uuidv4 } from 'uuid';
@@ -9,19 +11,19 @@ import { addMonths, subDays, subMilliseconds } from 'date-fns';
import { jwtVerify } from 'jose';
import { encryptJWTSecret } from '../main/utils/jwt';
import { LeanContract } from '../main/types/models/Contract';
-import { cleanupAuthResources, getTestAdminApiKey, getTestAdminUser } from './utils/auth';
-
-function isActivePricing(pricingVersion: string, service: LeanService): boolean {
- return Object.keys(service.activePricings).some(
- (activePricingVersion: string) => activePricingVersion === pricingVersion
- );
-}
-
-function isArchivedPricing(pricingVersion: string, service: LeanService): boolean {
- return Object.keys(service.archivedPricings).some(
- (archivedPricingVersion: string) => archivedPricingVersion === pricingVersion
- );
-}
+import { cleanupAuthResources } from './utils/auth';
+import { LeanUser } from '../main/types/models/User';
+import { createTestUser } from './utils/users/userTestUtils';
+import { resetEscapeVersion } from '../main/utils/helpers';
+import {
+ addApiKeyToOrganization,
+ createTestOrganization,
+} from './utils/organization/organizationTestUtils';
+import { LeanOrganization } from '../main/types/models/Organization';
+import { generateOrganizationApiKey } from '../main/utils/users/helpers';
+import { addPricingToService } from './utils/services/serviceTestUtils';
+
+const PETCLINIC_PRICING_PATH = path.resolve(__dirname, './data/pricings/petclinic-2025.yml');
const DETAILED_EVALUATION_EXPECTED_RESULT = {
'petclinic-pets': {
@@ -44,15 +46,27 @@ const DETAILED_EVALUATION_EXPECTED_RESULT = {
},
error: null,
},
- 'petclinic-calendar': {
- eval: true,
+ 'petclinic-multiDependentFeature': {
+ eval: true,
+ used: {
+ 'petclinic-maxPets': 0,
+ 'petclinic-maxVisits': 0,
+ },
+ limit: {
+ 'petclinic-maxVisits': 9,
+ 'petclinic-maxPets': 6,
+ },
+ error: null,
+ },
+ 'petclinic-calendar': {
+ eval: true,
used: {
'petclinic-calendarEventsCreationLimit': 0,
- },
+ },
limit: {
- 'petclinic-calendarEventsCreationLimit': 15,
- },
- error: null
+ 'petclinic-calendarEventsCreationLimit': 15,
+ },
+ error: null,
},
'petclinic-vetSelection': { eval: true, used: null, limit: null, error: null },
'petclinic-consultations': { eval: false, used: null, limit: null, error: null },
@@ -65,14 +79,62 @@ const DETAILED_EVALUATION_EXPECTED_RESULT = {
'petclinic-smartClinicReports': { eval: false, used: null, limit: null, error: null },
};
+function isActivePricing(pricingVersion: string, service: LeanService): boolean {
+ // Normalize version (convert underscores to dots)
+ const normalizedVersion = resetEscapeVersion(pricingVersion);
+
+ // Handle both Map and plain object (from API responses)
+ if (service.activePricings instanceof Map) {
+ return service.activePricings.has(normalizedVersion);
+ }
+ // Handle plain object from API responses
+ return Object.prototype.hasOwnProperty.call(service.activePricings, normalizedVersion);
+}
+
+function isArchivedPricing(pricingVersion: string, service: LeanService): boolean {
+ if (!service.archivedPricings) {
+ return false;
+ }
+
+ // Normalize version (convert underscores to dots)
+ const normalizedVersion = resetEscapeVersion(pricingVersion);
+
+ // Handle both Map and plain object (from API responses)
+ if (service.archivedPricings instanceof Map) {
+ return service.archivedPricings.has(normalizedVersion);
+ }
+ // Handle plain object from API responses
+ return Object.prototype.hasOwnProperty.call(service.archivedPricings, normalizedVersion);
+}
+
describe('Features API Test Suite', function () {
let app: Server;
- let adminApiKey: string;
+ let adminUser: LeanUser;
+ let ownerUser: LeanUser;
+ let testOrganization: LeanOrganization;
+ let testOrganizationApiKey: string;
+ let testService: LeanService;
beforeAll(async function () {
app = await getApp();
- await getTestAdminUser();
- adminApiKey = await getTestAdminApiKey();
+ adminUser = await createTestUser('ADMIN');
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ testOrganizationApiKey = generateOrganizationApiKey();
+
+ // Add API key to organization
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: testOrganizationApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', adminUser.apiKey)
+ .attach('pricing', PETCLINIC_PRICING_PATH);
+
+ expect(response.status).toEqual(201);
+ testService = response.body;
});
afterAll(async function () {
@@ -80,9 +142,7 @@ describe('Features API Test Suite', function () {
await shutdownApp();
});
- let petclinicService: any;
-
- async function createTestContract(userId = uuidv4()) {
+ async function createPetclinicTestContract(userId = uuidv4()) {
const contractData = {
userContact: {
userId,
@@ -92,14 +152,15 @@ describe('Features API Test Suite', function () {
autoRenew: true,
renewalDays: 365,
},
+ organizationId: testOrganization.id!,
contractedServices: {
- [petclinicService.name]: Object.keys(petclinicService.activePricings)[0],
+ [testService.name]: Object.keys(testService.activePricings)[0],
},
subscriptionPlans: {
- [petclinicService.name]: 'GOLD',
+ [testService.name]: 'GOLD',
},
subscriptionAddOns: {
- [petclinicService.name]: {
+ [testService.name]: {
petAdoptionCentre: 1,
extraPets: 2,
extraVisits: 6,
@@ -109,34 +170,17 @@ describe('Features API Test Suite', function () {
const createContractResponse = await request(app)
.post(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send(contractData);
return createContractResponse.body;
}
- // Custom describe for evaluation testing
- const evaluationDescribe = (name: string, fn: () => void) => {
- describe(name, () => {
- fn();
- beforeAll(async function () {
- const createServiceResponse = await request(app)
- .post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', 'src/test/data/pricings/petclinic-2025.yml');
-
- if (createServiceResponse.status === 201) {
- petclinicService = createServiceResponse.body;
- }
- });
- });
- };
-
describe('GET /features', function () {
it('Should return 200 and the features', async function () {
const response = await request(app)
.get(`${baseUrl}/features?show=all`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -145,10 +189,10 @@ describe('Features API Test Suite', function () {
});
it('Should filter features by featureName', async function () {
- const featureName = 'meetings';
+ const featureName = 'pets';
const response = await request(app)
.get(`${baseUrl}/features?featureName=${featureName}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -159,10 +203,10 @@ describe('Features API Test Suite', function () {
});
it('Should filter features by serviceName', async function () {
- const serviceName = 'zoom';
+ const serviceName = 'petclinic';
const response = await request(app)
.get(`${baseUrl}/features?serviceName=${serviceName}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -175,10 +219,12 @@ describe('Features API Test Suite', function () {
});
it('Should filter features by pricingVersion', async function () {
- const pricingVersion = '2.0.0';
+ const pricingVersion = '2025-03-26';
+ await addPricingToService(testOrganization.id!, testService.name, '2.0.0');
+
const response = await request(app)
.get(`${baseUrl}/features?pricingVersion=${pricingVersion}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -193,7 +239,7 @@ describe('Features API Test Suite', function () {
const limit = 5;
const response = await request(app)
.get(`${baseUrl}/features?page=${page}&limit=${limit}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -206,7 +252,7 @@ describe('Features API Test Suite', function () {
const limit = 5;
const response = await request(app)
.get(`${baseUrl}/features?offset=${offset}&limit=${limit}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -217,7 +263,7 @@ describe('Features API Test Suite', function () {
it('Should sort results by featureName in ascending order', async function () {
const response = await request(app)
.get(`${baseUrl}/features?sort=featureName&order=asc`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -233,7 +279,7 @@ describe('Features API Test Suite', function () {
it('Should sort results by serviceName in descending order', async function () {
const response = await request(app)
.get(`${baseUrl}/features?sort=serviceName&order=desc`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -245,8 +291,12 @@ describe('Features API Test Suite', function () {
});
it('Should show only active features by default', async function () {
- const response = await request(app).get(`${baseUrl}/features`).set('x-api-key', adminApiKey);
- const responseServices = await request(app).get(`${baseUrl}/services`).set('x-api-key', adminApiKey);
+ const response = await request(app)
+ .get(`${baseUrl}/features`)
+ .set('x-api-key', testOrganizationApiKey);
+ const responseServices = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', testOrganizationApiKey);
const services = responseServices.body;
@@ -263,8 +313,12 @@ describe('Features API Test Suite', function () {
});
it('Should show only archived features when specified', async function () {
- const response = await request(app).get(`${baseUrl}/features?show=archived`).set('x-api-key', adminApiKey);
- const responseServices = await request(app).get(`${baseUrl}/services`).set('x-api-key', adminApiKey);
+ const response = await request(app)
+ .get(`${baseUrl}/features?show=archived`)
+ .set('x-api-key', testOrganizationApiKey);
+ const responseServices = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', testOrganizationApiKey);
const services = responseServices.body;
@@ -280,13 +334,13 @@ describe('Features API Test Suite', function () {
});
it('Should combine multiple query parameters correctly', async function () {
- const serviceName = 'zoom';
+ const serviceName = 'petclinic';
const limit = 5;
const sort = 'featureName';
const response = await request(app)
.get(`${baseUrl}/features?serviceName=${serviceName}&limit=${limit}&sort=${sort}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -308,7 +362,7 @@ describe('Features API Test Suite', function () {
it('Should handle invalid query parameters gracefully', async function () {
const response = await request(app)
.get(`${baseUrl}/features?invalidParam=value`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
@@ -316,18 +370,19 @@ describe('Features API Test Suite', function () {
});
});
- evaluationDescribe('POST /features/:userId', function () {
+ describe('POST /features/:userId', function () {
it('Should return 200 and the evaluation for a user', async function () {
- const newContract = await createTestContract();
+ const newContract = await createPetclinicTestContract();
const response = await request(app)
.post(`${baseUrl}/features/${newContract.userContact.userId}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
'petclinic-pets': true,
'petclinic-visits': true,
+ "petclinic-multiDependentFeature": true,
'petclinic-calendar': true,
'petclinic-vetSelection': true,
'petclinic-consultations': false,
@@ -341,23 +396,68 @@ describe('Features API Test Suite', function () {
});
});
+ it('Should return 200 and evaluation for a user with plan case mismatch', async function () {
+ const contractData = {
+ userContact: {
+ userId: uuidv4(),
+ username: 'tUserCaseTest',
+ },
+ billingPeriod: {
+ autoRenew: true,
+ renewalDays: 365,
+ },
+ organizationId: testOrganization.id!,
+ contractedServices: {
+ [testService.name]: Object.keys(testService.activePricings)[0],
+ },
+ subscriptionPlans: {
+ [testService.name]: 'platinum', // Use lowercase while pricing has PLATINUM
+ },
+ subscriptionAddOns: {
+ [testService.name]: {
+ petAdoptionCentre: 1,
+ extraPets: 2,
+ extraVisits: 6,
+ },
+ },
+ };
+
+ const createContractResponse = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', testOrganizationApiKey)
+ .send(contractData);
+
+ expect(createContractResponse.status).toEqual(201);
+
+ const response = await request(app)
+ .post(`${baseUrl}/features/${contractData.userContact.userId}`)
+ .set('x-api-key', testOrganizationApiKey);
+
+ expect(response.status).toEqual(200);
+ expect(response.body).toBeDefined();
+ expect(Object.keys(response.body).length).toBeGreaterThan(0);
+ // Verify that the features are evaluated correctly despite the plan case mismatch
+ expect(response.body['petclinic-pets']).toBeTruthy();
+ expect(response.body['petclinic-visits']).toBeTruthy();
+ });
+
it('Should return 200 and visits as false since its limit has been reached', async function () {
const testUserId = uuidv4();
- await createTestContract(testUserId);
+ await createPetclinicTestContract(testUserId);
// Reach the limit of 9 visits
await request(app)
.put(`${baseUrl}/contracts/${testUserId}/usageLevels`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
- [petclinicService.name.toLowerCase()]: {
+ [testService.name.toLowerCase()]: {
maxVisits: 9,
},
});
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body['petclinic-visits']).toBeFalsy();
@@ -365,12 +465,12 @@ describe('Features API Test Suite', function () {
it('Given expired user subscription but with autoRenew = true should return 200', async function () {
const testUserId = uuidv4();
- await createTestContract(testUserId);
+ await createPetclinicTestContract(testUserId);
// Expire user subscription
await request(app)
.put(`${baseUrl}/contracts/${testUserId}/billingPeriod`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
endDate: subDays(new Date(), 1),
autoRenew: true,
@@ -378,14 +478,16 @@ describe('Features API Test Suite', function () {
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(Object.keys(response.body).length).toBeGreaterThan(0);
- const userContract = (await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)).body;
+ const userContract = (
+ await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ ).body;
expect(new Date(userContract.billingPeriod.endDate).getFullYear()).toBe(
new Date().getFullYear() + 1
); // + 1 year because the test contract is set to renew 1 year
@@ -393,12 +495,12 @@ describe('Features API Test Suite', function () {
it('Given expired user subscription with autoRenew = false should return 400', async function () {
const testUserId = uuidv4();
- await createTestContract(testUserId);
+ await createPetclinicTestContract(testUserId);
// Expire user subscription
await request(app)
.put(`${baseUrl}/contracts/${testUserId}/billingPeriod`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
endDate: subMilliseconds(new Date(), 1),
autoRenew: false,
@@ -406,7 +508,7 @@ describe('Features API Test Suite', function () {
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(400);
expect(response.body.error).toEqual(
@@ -415,25 +517,25 @@ describe('Features API Test Suite', function () {
});
it('Should return 200 and a detailed evaluation for a user', async function () {
- const newContract = await createTestContract();
+ const newContract = await createPetclinicTestContract();
const response = await request(app)
.post(`${baseUrl}/features/${newContract.userContact.userId}?details=true`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toEqual(DETAILED_EVALUATION_EXPECTED_RESULT);
});
});
- evaluationDescribe('POST /features/:userId/pricing-token', function () {
+ describe('POST /features/:userId/pricing-token', function () {
it('Should return 200 and the evaluation for a user', async function () {
const userId = uuidv4();
- const newContract = await createTestContract(userId);
+ const newContract = await createPetclinicTestContract(userId);
const response = await request(app)
.post(`${baseUrl}/features/${userId}/pricing-token`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body.pricingToken).toBeDefined();
@@ -459,13 +561,13 @@ describe('Features API Test Suite', function () {
});
});
- evaluationDescribe('POST /features/:userId/:featureId', function () {
+ describe('POST /features/:userId/:featureId', function () {
let testUserId: string;
let testFeatureId: string;
let testUsageLimitId: string;
beforeEach(async function () {
- const newContract: LeanContract = await createTestContract();
+ const newContract: LeanContract = await createPetclinicTestContract();
const testServiceName = Object.keys(newContract.usageLevels)[0].toLowerCase();
const testFeatureName = 'visits';
const testUsageLimitName = 'maxVisits';
@@ -477,7 +579,7 @@ describe('Features API Test Suite', function () {
it('Should return 200 and the feature evaluation', async function () {
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}/${testFeatureId}`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testOrganizationApiKey);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
@@ -495,12 +597,11 @@ describe('Features API Test Suite', function () {
it('Should return 200: Given expected consumption', async function () {
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}/${testFeatureId}`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
[testUsageLimitId]: 1,
});
-
expect(response.status).toEqual(200);
expect(response.body).toEqual({
used: {
@@ -513,9 +614,11 @@ describe('Features API Test Suite', function () {
error: null,
});
- const contractAfter = (await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)).body;
+ const contractAfter = (
+ await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ ).body;
expect(contractAfter.usageLevels).toBeDefined();
const usageLevelService = testUsageLimitId.split('-')[0];
@@ -525,22 +628,37 @@ describe('Features API Test Suite', function () {
expect(contractAfter.usageLevels[usageLevelService][usageLevelName].consumed).toEqual(1);
});
+ it('Should return 200 and INVALID_EXPECTED_CONSUMPTION: Given incomplete expected consumption', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/${testUserId}/petclinic-multiDependentFeature`)
+ .set('x-api-key', testOrganizationApiKey)
+ .send({
+ [testUsageLimitId]: 1,
+ });
+
+ expect(response.status).toEqual(200);
+ expect(response.body.error).toBeDefined();
+ expect(response.body.error.code).toBe("INVALID_EXPECTED_CONSUMPTION");
+ })
+
it('Should return 200: Given expired renewable usage level', async function () {
-
const serviceName = testUsageLimitId.split('-')[0];
const usageLevelName = testUsageLimitId.split('-')[1];
await request(app)
.put(`${baseUrl}/contracts/${testUserId}/usageLevels`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
[serviceName]: {
[usageLevelName]: 4,
- }});
+ },
+ });
- const contractBefore = (await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)).body;
+ const contractBefore = (
+ await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ ).body;
expect(contractBefore.usageLevels).toBeDefined();
expect(contractBefore.usageLevels[serviceName]).toBeDefined();
expect(contractBefore.usageLevels[serviceName][usageLevelName]).toBeDefined();
@@ -548,23 +666,16 @@ describe('Features API Test Suite', function () {
vi.useFakeTimers();
vi.setSystemTime(addMonths(new Date(), 2)); // Enough to expire the renewable usage level
-
- // Mock de CacheService
- vi.mock('../main/services/CacheService', () => {
- return {
- default: class MockCacheService {
- get = vi.fn().mockResolvedValue(null);
- set = vi.fn().mockResolvedValue(undefined);
- match = vi.fn().mockResolvedValue([]);
- del = vi.fn().mockResolvedValue(undefined);
- setRedisClient = vi.fn();
- }
- };
- });
+
+ const cacheService = container.resolve('cacheService');
+ const cacheGetSpy = vi.spyOn(cacheService, 'get').mockResolvedValue(null);
+ const cacheSetSpy = vi.spyOn(cacheService, 'set').mockResolvedValue(undefined);
+ const cacheMatchSpy = vi.spyOn(cacheService, 'match').mockResolvedValue([]);
+ const cacheDelSpy = vi.spyOn(cacheService, 'del').mockResolvedValue(undefined);
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}/${testFeatureId}`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
[testUsageLimitId]: 1,
});
@@ -581,34 +692,41 @@ describe('Features API Test Suite', function () {
error: null,
});
- const contractAfter = (await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)).body;
+ const contractAfter = (
+ await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ ).body;
expect(contractAfter.usageLevels).toBeDefined();
expect(contractAfter.usageLevels[serviceName][usageLevelName]).toBeDefined();
expect(contractAfter.usageLevels[serviceName][usageLevelName].consumed).toEqual(1);
+ cacheGetSpy.mockRestore();
+ cacheSetSpy.mockRestore();
+ cacheMatchSpy.mockRestore();
+ cacheDelSpy.mockRestore();
vi.useRealTimers();
- vi.clearAllMocks();
});
it('Should return 200: Given expired renewable usage levels should reset all and evaluate one', async function () {
-
const serviceName = testUsageLimitId.split('-')[0];
const usageLevelName = testUsageLimitId.split('-')[1];
await request(app)
.put(`${baseUrl}/contracts/${testUserId}/usageLevels`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
[serviceName]: {
[usageLevelName]: 4,
- calendarEventsCreationLimit: 10
- }});
+ calendarEventsCreationLimit: 10,
+ },
+ });
- const contractBefore = (await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)).body;
+ const contractBefore = (
+ await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ ).body;
expect(contractBefore.usageLevels).toBeDefined();
expect(contractBefore.usageLevels[serviceName]).toBeDefined();
expect(contractBefore.usageLevels[serviceName][usageLevelName]).toBeDefined();
@@ -616,23 +734,16 @@ describe('Features API Test Suite', function () {
vi.useFakeTimers();
vi.setSystemTime(addMonths(new Date(), 2)); // Enough to expire the renewable usage level
-
- // Mock de CacheService
- vi.mock('../main/services/CacheService', () => {
- return {
- default: class MockCacheService {
- get = vi.fn().mockResolvedValue(null);
- set = vi.fn().mockResolvedValue(undefined);
- match = vi.fn().mockResolvedValue([]);
- del = vi.fn().mockResolvedValue(undefined);
- setRedisClient = vi.fn();
- }
- };
- });
+
+ const cacheService = container.resolve('cacheService');
+ const cacheGetSpy = vi.spyOn(cacheService, 'get').mockResolvedValue(null);
+ const cacheSetSpy = vi.spyOn(cacheService, 'set').mockResolvedValue(undefined);
+ const cacheMatchSpy = vi.spyOn(cacheService, 'match').mockResolvedValue([]);
+ const cacheDelSpy = vi.spyOn(cacheService, 'del').mockResolvedValue(undefined);
const response = await request(app)
.post(`${baseUrl}/features/${testUserId}/${testFeatureId}`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testOrganizationApiKey)
.send({
[testUsageLimitId]: 1,
});
@@ -649,17 +760,59 @@ describe('Features API Test Suite', function () {
error: null,
});
- const contractAfter = (await request(app)
- .get(`${baseUrl}/contracts/${testUserId}`)
- .set('x-api-key', adminApiKey)).body;
+ const contractAfter = (
+ await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ ).body;
expect(contractAfter.usageLevels).toBeDefined();
expect(contractAfter.usageLevels[serviceName][usageLevelName]).toBeDefined();
expect(contractAfter.usageLevels[serviceName][usageLevelName].consumed).toEqual(1);
expect(contractAfter.usageLevels[serviceName].calendarEventsCreationLimit).toBeDefined();
- expect(contractAfter.usageLevels[serviceName].calendarEventsCreationLimit.consumed).toEqual(0);
+ expect(contractAfter.usageLevels[serviceName].calendarEventsCreationLimit.consumed).toEqual(
+ 0
+ );
+ cacheGetSpy.mockRestore();
+ cacheSetSpy.mockRestore();
+ cacheMatchSpy.mockRestore();
+ cacheDelSpy.mockRestore();
vi.useRealTimers();
- vi.clearAllMocks();
+ });
+
+ it('Should return 200 and revert usageLimit', async function () {
+ const evaluationResponse = await request(app)
+ .post(`${baseUrl}/features/${testUserId}/${testFeatureId}`)
+ .set('x-api-key', testOrganizationApiKey)
+ .send({
+ [testUsageLimitId]: 5,
+ });
+
+ expect(evaluationResponse.status).toEqual(200);
+ expect(evaluationResponse.body.used[testUsageLimitId]).toEqual(5);
+
+ const revertResponse = await request(app)
+ .post(`${baseUrl}/features/${testUserId}/${testFeatureId}?revert=true`)
+ .set('x-api-key', testOrganizationApiKey);
+
+ expect(revertResponse.status).toEqual(204);
+
+ const contractAfterRevert = await request(app)
+ .get(`${baseUrl}/contracts/${testUserId}`)
+ .set('x-api-key', testOrganizationApiKey);
+
+ expect(contractAfterRevert.status).toEqual(200);
+ expect(contractAfterRevert.body.usageLevels[testFeatureId.split("-")[0]][testUsageLimitId.split("-")[1]].consumed).toEqual(0);
+ });
+
+ it('Should return 200 and error for non-existing feature', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/${testUserId}/non-existing-feature`)
+ .set('x-api-key', testOrganizationApiKey);
+
+ expect(response.status).toEqual(200);
+ expect(response.body.error).toBeDefined();
+ expect(response.body.error.code).toBe("FLAG_NOT_FOUND");
});
});
-});
\ No newline at end of file
+});
diff --git a/api/src/test/middlewares/authMiddleware.test.ts b/api/src/test/middlewares/authMiddleware.test.ts
new file mode 100644
index 0000000..3c0daeb
--- /dev/null
+++ b/api/src/test/middlewares/authMiddleware.test.ts
@@ -0,0 +1,902 @@
+import request from 'supertest';
+import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
+import { Server } from 'http';
+import { getApp, shutdownApp, baseUrl } from '../utils/testApp';
+import { createTestUser, deleteTestUser } from '../utils/users/userTestUtils';
+import { createTestOrganization, deleteTestOrganization, addMemberToOrganization, addApiKeyToOrganization } from '../utils/organization/organizationTestUtils';
+import { createTestService, deleteTestService, getRandomPricingFile } from '../utils/services/serviceTestUtils';
+import { LeanUser } from '../../main/types/models/User';
+import { LeanOrganization } from '../../main/types/models/Organization';
+import { LeanService } from '../../main/types/models/Service';
+import { generateOrganizationApiKey } from '../../main/utils/users/helpers';
+
+describe('Authentication Middleware Test Suite', function () {
+ let app: Server;
+ let adminUser: LeanUser;
+ let regularUser: LeanUser;
+ let evaluatorUser: LeanUser;
+ let testOrganization: LeanOrganization;
+ let testOrganizationWithoutMembers: LeanOrganization;
+ let testService: LeanService;
+
+ beforeAll(async function () {
+ app = await getApp();
+
+ // Create test users
+ adminUser = await createTestUser('ADMIN');
+ regularUser = await createTestUser('USER');
+ evaluatorUser = await createTestUser('USER');
+
+ // Create test organizations
+ testOrganization = await createTestOrganization(regularUser.username);
+ testOrganizationWithoutMembers = await createTestOrganization();
+
+ // Add members to organization
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+
+ // Create test service
+ testService = await createTestService(testOrganization.id);
+ });
+
+ afterAll(async function () {
+ // Clean up
+ if (testService?.name && testOrganization?.id) {
+ await deleteTestService(testService.name, testOrganization.id);
+ }
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (testOrganizationWithoutMembers?.id) {
+ await deleteTestOrganization(testOrganizationWithoutMembers.id);
+ }
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (regularUser?.username) {
+ await deleteTestUser(regularUser.username);
+ }
+ if (evaluatorUser?.username) {
+ await deleteTestUser(evaluatorUser.username);
+ }
+ await shutdownApp();
+ });
+
+ describe('authenticateApiKeyMiddleware - API Key Format Validation', function () {
+ it('Should allow access to public routes without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/healthcheck`);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 401 for protected routes without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/users`);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+
+ it('Should return 401 with invalid API key format (missing prefix)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', 'invalid-api-key-without-prefix');
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('Invalid API Key format');
+ });
+
+ it('Should return 401 with invalid API key format (wrong prefix)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', 'wrong_someapikey');
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('Invalid API Key format');
+ });
+ });
+
+ describe('authenticateApiKeyMiddleware - User API Key Authentication', function () {
+ it('Should authenticate valid user API key (usr_ prefix)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body.data)).toBe(true);
+ });
+
+ it('Should return 401 with non-existent user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', 'usr_nonexistentkeyvalue');
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('INVALID DATA: Invalid API Key');
+ });
+
+ it('Should set req.user for valid user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.username).toBe(adminUser.username);
+ });
+
+ it('Should enrich req.user with complete user data including role', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('username');
+ expect(response.body).toHaveProperty('role');
+ expect(response.body.role).toBe('ADMIN');
+ });
+
+ it('Should enrich req.user correctly for regular USER role', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${regularUser.username}`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.username).toBe(regularUser.username);
+ expect(response.body.role).toBe('USER');
+ });
+
+ it('Should not enrich req.org when using user API key', async function () {
+ // When using user API key, req.org should not be set
+ const response = await request(app)
+ .get(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ // req.org is not set, only req.user - verified by successful access to user-only route
+ });
+ });
+
+ describe('authenticateApiKeyMiddleware - Organization API Key Authentication', function () {
+ let orgApiKey: string;
+
+ beforeEach(async function () {
+ orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+ });
+
+ it('Should authenticate valid organization API key (org_ prefix)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ });
+
+ it('Should return 401 with non-existent organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', 'org_nonexistentkeyvalue');
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('Invalid Organization API Key');
+ });
+
+ it('Should set req.org with organization details for valid org API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ });
+
+ it('Should enrich req.org with correct organization ID, name, and role', async function () {
+ // We can verify this indirectly by checking that org-specific operations work
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(200);
+ // If req.org wasn't properly set, this would fail with 401 or 403
+ });
+
+ it('Should set correct scope in req.org based on API key scope', async function () {
+ const managementApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: managementApiKey,
+ scope: 'MANAGEMENT',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', managementApiKey);
+
+ expect(response.status).toBe(200);
+ // Verify that MANAGEMENT scope can read services
+ });
+ });
+
+ describe('checkPermissions - Role-Based Access Control', function () {
+ it('Should allow ADMIN user to access admin-only routes', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should deny regular user access to admin-only routes', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('does not have permission');
+ });
+
+ it('Should allow USER role to access user-specific routes', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${regularUser.username}`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.username).toBe(regularUser.username);
+ });
+
+ it('Should deny organization API key access to user-only routes', async function () {
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('requires a user API key');
+ });
+
+ it('Should allow organization API key with ALL scope to access organization API key routes', async function () {
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should deny organization API key with MANAGEMENT scope to access EVALUATION-only routes', async function () {
+ const managementApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: managementApiKey,
+ scope: 'MANAGEMENT',
+ });
+
+ // GET /services requires EVALUATION scope in some cases, but MANAGEMENT should also work
+ // Let's test a route that requires specific scope
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', managementApiKey);
+
+ // MANAGEMENT scope allows reading services
+ expect([200, 403]).toContain(response.status);
+ });
+
+ it('Should allow organization API key with EVALUATION scope to read services', async function () {
+ const evaluationApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: evaluationApiKey,
+ scope: 'EVALUATION',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', evaluationApiKey);
+
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('memberRole - Organization Membership Validation', function () {
+ it('Should return 401 when accessing organization routes without authentication', async function () {
+ const response = await request(app).get(
+ `${baseUrl}/organizations/${testOrganization.id}/services`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/nonexistentorgid/services`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('INVALID DATA');
+ });
+
+ it('Should allow owner to access organization routes', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should allow organization member to access organization routes', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should deny non-member access to organization routes', async function () {
+ const nonMemberUser = await createTestUser('USER');
+
+ try {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', nonMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('not a member');
+ } finally {
+ await deleteTestUser(nonMemberUser.username);
+ }
+ });
+
+ it('Should allow ADMIN user to access any organization without explicit membership', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 403 if organization API key tries to access organization-scoped routes', async function () {
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should enrich req.user.orgRole as OWNER when user is organization owner', async function () {
+ // regularUser is the owner of testOrganization
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(200);
+ // If orgRole wasn't set to OWNER, subsequent permission checks would fail
+ });
+
+ it('Should enrich req.user.orgRole with member role when user is a member', async function () {
+ // evaluatorUser is a member with EVALUATOR role
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(response.status).toBe(200);
+ // If orgRole wasn't properly set, this would fail
+ });
+
+ it('Should not set req.user.orgRole for ADMIN users (they bypass member check)', async function () {
+ // Admin users can access without being members, so orgRole isn't set
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ // Admin bypasses the orgRole requirement in hasPermission
+ });
+
+ it('Should set req.user.orgRole correctly for MANAGER members', async function () {
+ const managerUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+
+ try {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', managerUser.apiKey);
+
+ expect(response.status).toBe(200);
+ } finally {
+ await deleteTestUser(managerUser.username);
+ }
+ });
+
+ it('Should set req.user.orgRole correctly for org ADMIN members', async function () {
+ const orgAdminUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: orgAdminUser.username,
+ role: 'ADMIN',
+ });
+
+ try {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', orgAdminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ } finally {
+ await deleteTestUser(orgAdminUser.username);
+ }
+ });
+ });
+
+ describe('hasPermission - Specific Role Requirements', function () {
+ it('Should allow ADMIN user to bypass specific role requirements', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', adminUser.apiKey)
+ .send({ name: 'test-service' });
+
+ // Should either succeed or fail with a different error (not permission related)
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow OWNER to access routes requiring OWNER role', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should allow MANAGER to access routes requiring MANAGER role', async function () {
+ const managerUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+
+ try {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', managerUser.apiKey)
+ .send({ name: 'test-service' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ } finally {
+ await deleteTestUser(managerUser.username);
+ }
+ });
+
+ it('Should deny EVALUATOR access to routes requiring MANAGER role', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey)
+ .send({ name: 'test-service' });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('not have permission');
+ });
+
+ it('Should allow EVALUATOR access to read-only routes', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should deny EVALUATOR access to delete routes', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('Permission Hierarchy - Complex Scenarios', function () {
+ it('Should handle cascading permission checks correctly', async function () {
+ // Non-member trying to access organization services
+ const nonMemberUser = await createTestUser('USER');
+
+ try {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', nonMemberUser.apiKey);
+
+ // Should fail at memberRole middleware
+ expect(response.status).toBe(403);
+ } finally {
+ await deleteTestUser(nonMemberUser.username);
+ }
+ });
+
+ it('Should validate both membership and role permissions', async function () {
+ // Member with EVALUATOR role trying to delete services
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ // Should fail at hasPermission middleware (requires OWNER or ADMIN)
+ expect(response.status).toBe(403);
+ });
+
+ it('Should handle service-specific operations with role validation', async function () {
+ // EVALUATOR trying to update service
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/services/${testService.name}`)
+ .set('x-api-key', evaluatorUser.apiKey)
+ .send({ name: 'updated-name' });
+
+ // Should fail - EVALUATOR cannot modify services
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow MANAGER to update service properties', async function () {
+ const managerUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+
+ try {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/services/${testService.name}`)
+ .set('x-api-key', managerUser.apiKey)
+ .send({ name: testService.name }); // Same name to avoid conflict
+
+ // Should succeed or fail with validation error, not permission error
+ expect([200, 400, 422]).toContain(response.status);
+ } finally {
+ await deleteTestUser(managerUser.username);
+ }
+ });
+
+ it('Should handle pricing-specific operations with role validation', async function () {
+ // EVALUATOR trying to archive pricing
+ const response = await request(app)
+ .put(
+ `${baseUrl}/organizations/${testOrganization.id}/services/${testService.name}/pricings/1.0.0`
+ )
+ .set('x-api-key', evaluatorUser.apiKey)
+ .send({ availability: 'archived', subscriptionPlan: 'BASEBOARD' });
+
+ // Should fail - EVALUATOR cannot modify pricings
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('Organization API Key Scope Validation', function () {
+ let allScopeApiKey: string;
+ let managementScopeApiKey: string;
+ let evaluationScopeApiKey: string;
+
+ beforeEach(async function () {
+ allScopeApiKey = generateOrganizationApiKey();
+ managementScopeApiKey = generateOrganizationApiKey();
+ evaluationScopeApiKey = generateOrganizationApiKey();
+
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: allScopeApiKey,
+ scope: 'ALL',
+ });
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: managementScopeApiKey,
+ scope: 'MANAGEMENT',
+ });
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: evaluationScopeApiKey,
+ scope: 'EVALUATION',
+ });
+ });
+
+ it('Should allow ALL scope to read services', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', allScopeApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should allow MANAGEMENT scope to read and write services', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', managementScopeApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should allow EVALUATION scope to read services', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', evaluationScopeApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should deny EVALUATION scope from creating services', async function () {
+ const pricingPath = await getRandomPricingFile();
+
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', evaluationScopeApiKey)
+ .attach('pricing', pricingPath);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow ALL scope to create contracts', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', allScopeApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should allow MANAGEMENT scope to read contracts', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', managementScopeApiKey);
+
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('Edge Cases and Error Handling', function () {
+ it('Should handle empty API key header gracefully', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', '');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should handle malformed permission rules gracefully', async function () {
+ // Test with a path that doesn't match any rule
+ const response = await request(app)
+ .get(`${baseUrl}/nonexistent-route`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should properly handle simultaneous requests with different auth types', async function () {
+ const orgAllApiKey = generateOrganizationApiKey();
+ const orgManagerApiKey = generateOrganizationApiKey();
+
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgAllApiKey,
+ scope: 'ALL',
+ });
+
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgManagerApiKey,
+ scope: 'MANAGEMENT',
+ });
+
+ const [allResponse, managerResponse] = await Promise.all([
+ request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgAllApiKey),
+ request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgManagerApiKey),
+ ]);
+
+ expect(allResponse.status).toBe(200);
+ expect(managerResponse.status).toBe(200);
+ });
+
+ it('Should validate route method matching in permissions', async function () {
+ // EVALUATOR trying POST (should fail, requires MANAGER)
+ const postResponse = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey)
+ .send({ name: 'test' });
+
+ expect(postResponse.status).toBe(403);
+
+ // EVALUATOR trying GET (should succeed)
+ const getResponse = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(getResponse.status).toBe(200);
+ });
+ });
+
+ describe('Request Object Enrichment Validation', function () {
+ it('Should properly enrich req.user and set authType to "user" for user API keys', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ // req.user is set with user data, authType is 'user'
+ });
+
+ it('Should properly enrich req.org and set authType to "organization" for org API keys', async function () {
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(200);
+ // req.org is set with organization data, authType is 'organization'
+ });
+
+ it('Should enrich req.user.orgRole to OWNER when accessing as organization owner', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should enrich req.user.orgRole to EVALUATOR when accessing as evaluator member', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should not enrich req.user.orgRole when user is not a member and not ADMIN', async function () {
+ const nonMemberUser = await createTestUser('USER');
+
+ try {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', nonMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('not a member');
+ } finally {
+ await deleteTestUser(nonMemberUser.username);
+ }
+ });
+
+ it('Should allow ADMIN users to bypass orgRole requirement', async function () {
+ // ADMIN users don't get orgRole set but can still access
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganizationWithoutMembers.id}/services`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should maintain separate req.user and req.org contexts', async function () {
+ const userResponse = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', adminUser.apiKey);
+
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const orgResponse = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', orgApiKey);
+
+ expect(userResponse.status).toBe(200);
+ expect(orgResponse.status).toBe(200);
+ // Both contexts work independently
+ });
+
+ it('Should enrich req.user.orgRole differently for different organizations', async function () {
+ // regularUser is OWNER in testOrganization
+ const response1 = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response1.status).toBe(200);
+
+ // regularUser is not a member in testOrganizationWithoutMembers
+ const response2 = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganizationWithoutMembers.id}/services`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect(response2.status).toBe(403);
+ expect(response2.body.error).toContain('not a member');
+ });
+ });
+
+ describe('Contract Routes Permission Tests', function () {
+ it('Should allow organization member to read contracts', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', regularUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow MANAGER to create contracts', async function () {
+ const managerUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+
+ try {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', managerUser.apiKey)
+ .send({
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: adminUser.username,
+ });
+
+ expect([201, 400, 422]).toContain(response.status);
+ } finally {
+ await deleteTestUser(managerUser.username);
+ }
+ });
+
+ it('Should deny EVALUATOR from creating contracts', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', evaluatorUser.apiKey)
+ .send({
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: adminUser.username,
+ });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should deny organization API key from accessing organization-scoped contract routes', async function () {
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow organization API key to access contract routes via /contracts endpoint', async function () {
+ const orgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {
+ key: orgApiKey,
+ scope: 'ALL',
+ });
+
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', orgApiKey);
+
+ expect(response.status).toBe(200);
+ });
+ });
+});
diff --git a/api/src/test/organization.test.ts b/api/src/test/organization.test.ts
new file mode 100644
index 0000000..24dbb25
--- /dev/null
+++ b/api/src/test/organization.test.ts
@@ -0,0 +1,2183 @@
+import request from 'supertest';
+import { baseUrl, getApp, shutdownApp } from './utils/testApp';
+import { Server } from 'http';
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
+import {
+ createTestOrganization,
+ addApiKeyToOrganization,
+ addMemberToOrganization,
+ deleteTestOrganization,
+} from './utils/organization/organizationTestUtils';
+import { LeanOrganization } from '../main/types/models/Organization';
+import { LeanUser } from '../main/types/models/User';
+import crypto, { randomUUID } from 'crypto';
+
+describe('Organization API Test Suite', function () {
+ let app: Server;
+ let adminUser: any;
+ let adminApiKey: string;
+ let regularUser: any;
+ let regularUserApiKey: string;
+
+ beforeAll(async function () {
+ app = await getApp();
+ // Create an admin user for tests
+ adminUser = await createTestUser('ADMIN');
+ adminApiKey = adminUser.apiKey;
+
+ // Create a regular user for tests
+ regularUser = await createTestUser('USER');
+ regularUserApiKey = regularUser.apiKey;
+ });
+
+ afterAll(async function () {
+ // Clean up the created users
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (regularUser?.username) {
+ await deleteTestUser(regularUser.username);
+ }
+ await shutdownApp();
+ });
+
+ describe('GET /organizations', function () {
+ let testOrganizations: LeanOrganization[] = [];
+
+ beforeAll(async function () {
+ // Create multiple test organizations for pagination tests
+ for (let i = 0; i < 15; i++) {
+ const org = await createTestOrganization(adminUser.username);
+ testOrganizations.push(org);
+ }
+ });
+
+ afterAll(async function () {
+ // Clean up test organizations
+ for (const org of testOrganizations) {
+ if (org.id) {
+ await deleteTestOrganization(org.id);
+ }
+ }
+ });
+
+ describe('Admin Users - Paginated Response', function () {
+ it('Should return 200 with paginated data structure for admin users', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body).toHaveProperty('data');
+ expect(response.body).toHaveProperty('pagination');
+ expect(Array.isArray(response.body.data)).toBe(true);
+ expect(response.body.pagination).toHaveProperty('offset');
+ expect(response.body.pagination).toHaveProperty('limit');
+ expect(response.body.pagination).toHaveProperty('total');
+ expect(response.body.pagination).toHaveProperty('page');
+ expect(response.body.pagination).toHaveProperty('pages');
+ });
+
+ it('Should return default pagination (limit=10, offset=0) when not specified', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.pagination.limit).toBe(10);
+ expect(response.body.pagination.offset).toBe(0);
+ expect(response.body.pagination.page).toBe(1);
+ expect(response.body.data.length).toBeLessThanOrEqual(10);
+ });
+
+ it('Should respect custom limit parameter', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?limit=5`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.pagination.limit).toBe(5);
+ expect(response.body.data.length).toBeLessThanOrEqual(5);
+ });
+
+ it('Should respect custom offset parameter', async function () {
+ const firstPage = await request(app)
+ .get(`${baseUrl}/organizations/?limit=5&offset=0`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ const secondPage = await request(app)
+ .get(`${baseUrl}/organizations/?limit=5&offset=5`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(secondPage.body.pagination.offset).toBe(5);
+ expect(secondPage.body.pagination.page).toBe(2);
+
+ // Verify different data if there are enough organizations
+ if (firstPage.body.pagination.total > 5) {
+ expect(firstPage.body.data[0].id).not.toBe(secondPage.body.data[0].id);
+ }
+ });
+
+ it('Should calculate total pages correctly', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?limit=5`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ const expectedPages = Math.ceil(response.body.pagination.total / 5) || 1;
+ expect(response.body.pagination.pages).toBe(expectedPages);
+ });
+
+ it('Should filter by organization name with query parameter', async function () {
+ // Create organization with unique name
+ const uniqueOrg = await createTestOrganization(adminUser.username);
+ const uniqueName = uniqueOrg.name;
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?q=${uniqueName}`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.data.length).toBeGreaterThan(0);
+ expect(response.body.data.some((org: any) => org.name === uniqueName)).toBe(true);
+
+ // Clean up
+ if (uniqueOrg.id) {
+ await deleteTestOrganization(uniqueOrg.id);
+ }
+ });
+
+ it('Should perform case-insensitive search', async function () {
+ // Create organization with known name
+ const testOrg = await createTestOrganization(adminUser.username);
+ const orgName = testOrg.name;
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?q=${orgName.toLowerCase()}`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.data.some((org: any) =>
+ org.name.toLowerCase().includes(orgName.toLowerCase())
+ )).toBe(true);
+
+ // Clean up
+ if (testOrg.id) {
+ await deleteTestOrganization(testOrg.id);
+ }
+ });
+
+ it('Should return 400 for invalid limit (too low)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?limit=0`)
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ expect(response.body.error).toContain('Limit must be between 1 and 50');
+ });
+
+ it('Should return 400 for invalid limit (too high)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?limit=51`)
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ expect(response.body.error).toContain('Limit must be between 1 and 50');
+ });
+
+ it('Should return 400 for invalid offset (negative)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?offset=-1`)
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ expect(response.body.error).toContain('Offset must be a non-negative number');
+ });
+
+ it('Should handle non-numeric limit gracefully', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?limit=abc`)
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should handle non-numeric offset gracefully', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?offset=xyz`)
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return all organizations when search query is empty', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?q=`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.data.length).toBeGreaterThan(0);
+ expect(response.body.pagination.total).toBeGreaterThan(0);
+ });
+
+ it('Should combine search and pagination parameters', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?q=test&limit=3&offset=0`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.pagination.limit).toBe(3);
+ expect(response.body.pagination.offset).toBe(0);
+ expect(response.body.data.length).toBeLessThanOrEqual(3);
+ });
+ });
+
+ describe('Regular Users - Non-Paginated Response', function () {
+ it('Should return 200 with non-paginated data structure for regular users', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/`)
+ .set('x-api-key', regularUserApiKey)
+ .expect(200);
+
+ expect(response.body).toHaveProperty('data');
+ expect(Array.isArray(response.body.data)).toBe(true);
+ expect(response.body).not.toHaveProperty('pagination');
+ });
+
+ it('Should return only organizations where user is owner or member', async function () {
+ const userOrg = await createTestOrganization(regularUser.username);
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/`)
+ .set('x-api-key', regularUserApiKey)
+ .expect(200);
+
+ expect(Array.isArray(response.body.data)).toBe(true);
+ // Regular users should see organizations where they are owner or member
+ expect(response.body.data.every((org: any) =>
+ org.owner === regularUser.username ||
+ org.members.some((m: any) => m.username === regularUser.username)
+ )).toBe(true);
+
+ // Clean up
+ if (userOrg.id) {
+ await deleteTestOrganization(userOrg.id);
+ }
+ });
+
+ it('Should include organizations where user is a member', async function () {
+ // Create an organization owned by admin
+ const regularUserOrg = await createTestOrganization(regularUser.username);
+ const adminOrg = await createTestOrganization(adminUser.username);
+
+ // Add regular user as a member
+ await addMemberToOrganization(adminOrg.id!, { username: regularUser.username, role: 'MANAGER' });
+
+ // Get organizations for regular user
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/`)
+ .set('x-api-key', regularUserApiKey)
+ .expect(200);
+
+ expect(Array.isArray(response.body.data)).toBe(true);
+
+ // Regular user should see organizations where they are owner
+ const ownedOrgs = response.body.data.filter((org: any) => org.owner === regularUser.username);
+ expect(ownedOrgs.length).toBeGreaterThan(0);
+
+ // Regular user should also see the organization where they are a member
+ const memberOrg = response.body.data.find((org: any) => org.id === adminOrg.id);
+ expect(memberOrg).toBeDefined();
+ expect(memberOrg.owner).toBe(adminUser.username);
+ expect(memberOrg.members.some((m: any) => m.username === regularUser.username)).toBe(true);
+
+ // Clean up
+ if (regularUserOrg.id) {
+ await deleteTestOrganization(regularUserOrg.id);
+ }
+ if (adminOrg.id) {
+ await deleteTestOrganization(adminOrg.id);
+ }
+ });
+
+ it('Should ignore pagination parameters for regular users', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/?limit=5&offset=10`)
+ .set('x-api-key', regularUserApiKey)
+ .expect(200);
+
+ expect(response.body).toHaveProperty('data');
+ expect(response.body).not.toHaveProperty('pagination');
+ // Should return all accessible organizations, not paginated
+ });
+ });
+ });
+
+ describe('POST /organizations', function () {
+ let testUser: any;
+ let createdOrganizations: LeanOrganization[] = [];
+
+ beforeEach(async function () {
+ testUser = await createTestUser('USER');
+ });
+
+ afterEach(async function () {
+ // Clean up created organizations and test user
+ if (testUser?.username) {
+ await deleteTestUser(testUser.username);
+ }
+ });
+
+ it('Should return 201 and create a new organization', async function () {
+ const organizationData = {
+ name: `Test Organization ${Date.now()}`,
+ owner: testUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organizationData)
+ .expect(201);
+
+ expect(response.body.name).toBe(organizationData.name);
+ expect(response.body.owner).toBe(organizationData.owner);
+ expect(response.body.apiKeys).toBeDefined();
+ expect(Array.isArray(response.body.apiKeys)).toBe(true);
+ expect(response.body.apiKeys.length).toBeGreaterThan(0);
+ expect(response.body.members).toBeDefined();
+ expect(response.body.default).toBeFalsy();
+ expect(Array.isArray(response.body.members)).toBe(true);
+
+ createdOrganizations.push(response.body);
+ });
+
+ it('Should return 201 and create a new default organization', async function () {
+ const organizationData = {
+ name: `Test Organization ${Date.now()}`,
+ owner: testUser.username,
+ default: true,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organizationData)
+ .expect(201);
+
+ expect(response.body.name).toBe(organizationData.name);
+ expect(response.body.owner).toBe(organizationData.owner);
+ expect(response.body.apiKeys).toBeDefined();
+ expect(Array.isArray(response.body.apiKeys)).toBe(true);
+ expect(response.body.apiKeys.length).toBeGreaterThan(0);
+ expect(response.body.members).toBeDefined();
+ expect(Array.isArray(response.body.members)).toBe(true);
+ expect(response.body.default).toBeTruthy();
+ createdOrganizations.push(response.body);
+ });
+
+ it('Should return 409 when creating a second default organization', async function () {
+ const organization1 = {
+ name: `Test Organization ${randomUUID()}`,
+ owner: testUser.username,
+ default: true,
+ };
+
+ const organization2 = {
+ name: `Test Organization ${randomUUID()}`,
+ owner: testUser.username,
+ default: true,
+ };
+
+ await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organization1)
+ .expect(201);
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organization2);
+
+ expect(response.status).toBe(409);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when creating organization without required fields', async function () {
+ const organizationData = {
+ name: `Test Organization ${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organizationData)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when owner user does not exist', async function () {
+ const organizationData = {
+ name: `Test Organization ${Date.now()}`,
+ owner: `nonexistent_user_${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organizationData)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when organization name is not provided', async function () {
+ const organizationData = {
+ owner: testUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organizationData)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when organization name is empty', async function () {
+ const organizationData = {
+ name: '',
+ owner: testUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', adminApiKey)
+ .send(organizationData)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ describe('GET /organizations/:organizationId', function () {
+ let testOrganization: LeanOrganization;
+
+ beforeAll(async function () {
+ testOrganization = await createTestOrganization();
+ });
+
+ it('Should return 200 and the organization details', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body.id).toBe(testOrganization.id);
+ expect(response.body.name).toBe(testOrganization.name);
+ expect(response.body.owner).toBeDefined();
+ expect(response.body.ownerDetails.username).toBe(testOrganization.owner);
+ expect(response.body.ownerDetails.password).toBeUndefined();
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${fakeId}`)
+ .set('x-api-key', adminApiKey)
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 with invalid organization ID format', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/invalid-id`)
+ .set('x-api-key', adminApiKey)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ describe('PUT /organizations/:organizationId', function () {
+ let ownerUser: any;
+ let otherUser: any;
+
+ beforeEach(async function () {
+ ownerUser = await createTestUser('USER');
+ otherUser = await createTestUser('USER');
+ });
+
+ afterEach(async function () {
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+ if (otherUser?.username) {
+ await deleteTestUser(otherUser.username);
+ }
+ });
+
+ it('Should return 200 and update organization name when owner request', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ name: `Updated Organization ${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.name).toBe(updateData.name);
+ });
+
+ it('Should return 200 and update organization name when SPACE admin request', async function () {
+ const adminApiKey = adminUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ name: `Updated Organization ${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.name).toBe(updateData.name);
+ });
+
+ it('Should return 200 and update organization owner when owner request', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+ const newOwner = otherUser.username;
+ const oldOwner = ownerUser.username;
+
+ const updateData = {
+ owner: newOwner,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.owner).toBe(newOwner);
+
+ // Verify old owner is now a member with ADMIN role
+ const oldOwnerMember = response.body.members.find((m: any) => m.username === oldOwner);
+ expect(oldOwnerMember).toBeDefined();
+ expect(oldOwnerMember.role).toBe('ADMIN');
+ });
+
+ it('Should return 200 and remove new owner from members when transferring ownership', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+ const newOwner = otherUser.username;
+ const oldOwner = ownerUser.username;
+
+ // Add the new owner as a member first
+ await request(app)
+ .post(`${baseUrl}/organizations/${testOrg.id}/members`)
+ .set('x-api-key', ownerApiKey)
+ .send({
+ username: newOwner,
+ role: 'MANAGER'
+ })
+ .expect(200);
+
+ // Transfer ownership to the member
+ const updateData = {
+ owner: newOwner,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.owner).toBe(newOwner);
+
+ // Verify old owner is now a member with ADMIN role
+ const oldOwnerMember = response.body.members.find((m: any) => m.username === oldOwner);
+ expect(oldOwnerMember).toBeDefined();
+ expect(oldOwnerMember.role).toBe('ADMIN');
+
+ // Verify new owner is NOT in members array anymore
+ const newOwnerMember = response.body.members.find((m: any) => m.username === newOwner);
+ expect(newOwnerMember).toBeUndefined();
+ });
+
+ it('Should return 200 and update organization owner when SPACE admin request', async function () {
+ const adminApiKey = adminUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+ const newOwner = otherUser.username;
+ const oldOwner = ownerUser.username;
+
+ const updateData = {
+ owner: newOwner,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.owner).toBe(newOwner);
+
+ // Verify old owner is now a member with ADMIN role
+ const oldOwnerMember = response.body.members.find((m: any) => m.username === oldOwner);
+ expect(oldOwnerMember).toBeDefined();
+ expect(oldOwnerMember.role).toBe('ADMIN');
+ });
+
+ it('Should return 200 and update organization default flag', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ default: true,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.default).toBe(updateData.default);
+ });
+
+ it('Should return 200 and update organization with new owner default flag', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const newOwner = await createTestUser('USER');
+ const ownerTestOrg1 = await createTestOrganization(ownerUser.username);
+ const ownerTestOrg2 = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ default: true,
+ owner: newOwner.username,
+ };
+
+ await request(app)
+ .put(`${baseUrl}/organizations/${ownerTestOrg1.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send({ default: true })
+ .expect(200);
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${ownerTestOrg2.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData);
+
+ expect(response.status).toBe(200);
+ expect(response.body.owner).toBe(newOwner.username);
+ expect(response.body.default).toBeTruthy();
+
+ await deleteTestOrganization(ownerTestOrg1.id!);
+ await deleteTestOrganization(ownerTestOrg2.id!);
+ await deleteTestUser(newOwner.username);
+ });
+
+ it('Should return 409 when trying to assign second default organization to owner', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg1 = await createTestOrganization(ownerUser.username);
+ const testOrg2 = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ default: true,
+ };
+
+ await request(app)
+ .put(`${baseUrl}/organizations/${testOrg1.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(200);
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg2.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData);
+
+ expect(response.status).toBe(409);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 409 when trying to assign second default organization to updated owner', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const newOwner = await createTestUser('USER');
+ const ownerTestOrg1 = await createTestOrganization(ownerUser.username);
+ const ownerTestOrg2 = await createTestOrganization(ownerUser.username);
+ const newOwnerTestOrg2 = await createTestOrganization(newOwner.username);
+
+ const updateData = {
+ default: true,
+ owner: newOwner.username,
+ };
+
+ await request(app)
+ .put(`${baseUrl}/organizations/${ownerTestOrg1.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send({ default: true })
+ .expect(200);
+
+ await request(app)
+ .put(`${baseUrl}/organizations/${newOwnerTestOrg2.id}`)
+ .set('x-api-key', newOwner.apiKey)
+ .send({ default: true })
+ .expect(200);
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${ownerTestOrg2.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData);
+
+ expect(response.status).toBe(409);
+ expect(response.body.error).toBeDefined();
+
+ await deleteTestOrganization(ownerTestOrg1.id!);
+ await deleteTestOrganization(ownerTestOrg2.id!);
+ await deleteTestOrganization(newOwnerTestOrg2.id!);
+ await deleteTestUser(newOwner.username);
+ });
+
+ it('Should return 409 when owner tries to transfer ownership of default organization', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ // Set organization as default
+ await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send({ default: true })
+ .expect(200);
+
+ const updateData = {
+ owner: otherUser.username,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData);
+
+ expect(response.status).toBe(409);
+ expect(response.body.error).toContain('Cannot transfer ownership of a default organization');
+ });
+
+ it('Should return 409 when SPACE admin tries to transfer ownership of default organization', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ // Set organization as default
+ await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send({ default: true })
+ .expect(200);
+
+ const updateData = {
+ owner: otherUser.username,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey)
+ .send(updateData);
+
+ expect(response.status).toBe(409);
+ expect(response.body.error).toContain('Cannot transfer ownership of a default organization');
+ });
+
+ it('Should return 403 when neither organization owner or SPACE admin', async function () {
+ const notOwnerApiKey = otherUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ name: `Updated Organization ${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', notOwnerApiKey)
+ .send(updateData)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when updating with non-existent owner', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ owner: `nonexistent_user_${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when updating with invalid name type', async function () {
+ const ownerApiKey = ownerUser.apiKey;
+ const testOrg = await createTestOrganization(ownerUser.username);
+
+ const updateData = {
+ name: 12345, // Invalid: should be string
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+ const ownerApiKey = ownerUser.apiKey;
+
+ const updateData = {
+ name: `Updated Organization ${Date.now()}`,
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${fakeId}`)
+ .set('x-api-key', ownerApiKey)
+ .send(updateData)
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ describe('POST /organizations/members', function () {
+ let testOrganization: LeanOrganization;
+ let ownerUser: any;
+ let managerUser: any;
+ let memberUser: any;
+ let regularUserNoPermission: any;
+
+ beforeEach(async function () {
+ ownerUser = await createTestUser('USER');
+ managerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ memberUser = await createTestUser('USER');
+ regularUserNoPermission = await createTestUser('USER');
+
+ // Add owner to organization
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+ });
+
+ afterEach(async function () {
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+ if (managerUser?.username) {
+ await deleteTestUser(managerUser.username);
+ }
+ if (memberUser?.username) {
+ await deleteTestUser(memberUser.username);
+ }
+ if (regularUserNoPermission?.username) {
+ await deleteTestUser(regularUserNoPermission.username);
+ }
+ });
+
+ it('Should return 200 and add member to organization with owner request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ username: memberUser.username, role: 'MANAGER' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and add member to organization with organization manager request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', managerUser.apiKey)
+ .send({ username: memberUser.username, role: 'EVALUATOR' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and add member to organization with SPACE admin request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: memberUser.username, role: 'EVALUATOR' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 400 when adding non-existent user as member', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: `nonexistent_user_${Date.now()}`, role: 'EVALUATOR' });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when user without org role tries to add member', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', regularUserNoPermission.apiKey)
+ .send({ username: memberUser.username, role: 'EVALUATOR' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when EVALUATOR tries to add member', async function () {
+ const evaluatorUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', evaluatorUser.apiKey)
+ .send({ username: memberUser.username, role: 'EVALUATOR' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when MANAGER tries to add ADMIN member', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', managerUser.apiKey)
+ .send({ username: memberUser.username, role: 'ADMIN' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when empty request body is sent', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({})
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when username field not sent', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ role: 'EVALUATOR' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when username field is empty', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: '', role: 'EVALUATOR' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role field is not sent', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: memberUser.username })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role field is empty', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: memberUser.username, role: '' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role field is invalid', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: memberUser.username, role: 'INVALID_ROLE' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role field is OWNER', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: memberUser.username, role: 'OWNER' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ describe('POST /organizations/api-keys', function () {
+ let orgOwner: LeanUser;
+ let adminMember: LeanUser;
+ let managerMember: LeanUser;
+ let evaluatorMember: LeanUser;
+ let testOrganization: LeanOrganization;
+ let regularUserNoPermission: any;
+
+ beforeEach(async function () {
+ orgOwner = await createTestUser('USER');
+ testOrganization = await createTestOrganization(orgOwner.username);
+ adminMember = await createTestUser('USER');
+ managerMember = await createTestUser('USER');
+ evaluatorMember = await createTestUser('USER');
+ regularUserNoPermission = await createTestUser('USER');
+
+ // Add members to organization
+ await addMemberToOrganization(testOrganization.id!, {
+ username: adminMember.username,
+ role: 'ADMIN',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerMember.username,
+ role: 'MANAGER',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorMember.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterEach(async function () {
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (regularUserNoPermission?.username) {
+ await deleteTestUser(regularUserNoPermission.username);
+ }
+ if (evaluatorMember?.username) {
+ await deleteTestUser(evaluatorMember.username);
+ }
+ if (managerMember?.username) {
+ await deleteTestUser(managerMember.username);
+ }
+ if (orgOwner?.username) {
+ await deleteTestUser(orgOwner.username);
+ }
+ });
+
+ it('Should return 200 and create new API key with scope ALL with ADMIN request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', adminApiKey)
+ .send({ scope: 'ALL' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and create new API key with scope ALL with OWNER request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', orgOwner.apiKey)
+ .send({ scope: 'ALL' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and create new API key with scope ALL with organization ADMIN request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', adminMember.apiKey)
+ .send({ scope: 'ALL' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and create new API key with scope MANAGEMENT with MANAGER request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', managerMember.apiKey)
+ .send({ scope: 'MANAGEMENT' })
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 403 and create new API key with scope ALL with MANAGER request', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', managerMember.apiKey)
+ .send({ scope: 'ALL' })
+ .expect(403);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 403 when user without org role tries to add API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', regularUserNoPermission.apiKey)
+ .send({ scope: 'ALL' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when creating API key with custom scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', adminApiKey)
+ .send({ scope: 'READ' })
+ .expect(400);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 400 when scope is missing', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', adminApiKey)
+ .send({})
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${fakeId}/api-keys`)
+ .set('x-api-key', adminApiKey)
+ .send({ scope: 'ALL' })
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 with invalid organization ID format', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/invalid-id/api-keys`)
+ .set('x-api-key', adminApiKey)
+ .send({ scope: 'ALL' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ describe('PUT /organizations/:organizationId/members/:username', function () {
+ let spaceAdmin: any;
+ let testOrganization: LeanOrganization;
+ let ownerUser: any;
+ let adminMember: any;
+ let managerMember: any;
+ let evaluatorMember: any;
+ let regularMember: any;
+ let regularUserNoPermission: any;
+
+ beforeAll(async function () {
+ spaceAdmin = await createTestUser('ADMIN');
+ });
+
+ afterAll(async function () {
+ if (spaceAdmin?.username) {
+ await deleteTestUser(spaceAdmin.username);
+ }
+ });
+
+ beforeEach(async function () {
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ adminMember = await createTestUser('USER');
+ managerMember = await createTestUser('USER');
+ evaluatorMember = await createTestUser('USER');
+ regularMember = await createTestUser('USER');
+ regularUserNoPermission = await createTestUser('USER');
+
+ // Add members to organization with different roles
+ await addMemberToOrganization(testOrganization.id!, {
+ username: adminMember.username,
+ role: 'ADMIN',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerMember.username,
+ role: 'MANAGER',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorMember.username,
+ role: 'EVALUATOR',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: regularMember.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterEach(async function () {
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+ if (adminMember?.username) {
+ await deleteTestUser(adminMember.username);
+ }
+ if (managerMember?.username) {
+ await deleteTestUser(managerMember.username);
+ }
+ if (evaluatorMember?.username) {
+ await deleteTestUser(evaluatorMember.username);
+ }
+ if (regularMember?.username) {
+ await deleteTestUser(regularMember.username);
+ }
+ if (regularUserNoPermission?.username) {
+ await deleteTestUser(regularUserNoPermission.username);
+ }
+ });
+
+ // Successful updates
+ it('Should return 200 and update member role with SPACE admin request', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', spaceAdmin.apiKey)
+ .send({ role: 'MANAGER' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBeDefined();
+ });
+
+ it('Should return 200 and update member role with OWNER request', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'ADMIN' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBeDefined();
+ });
+
+ it('Should return 200 and update member role with org ADMIN request', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', adminMember.apiKey)
+ .send({ role: 'MANAGER' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBeDefined();
+ });
+
+ it('Should return 200 and update member role with org MANAGER request', async function () {
+ const testManager = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: testManager.username,
+ role: 'MANAGER',
+ });
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${testManager.username}`)
+ .set('x-api-key', managerMember.apiKey)
+ .send({ role: 'EVALUATOR' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBeDefined();
+
+ await deleteTestUser(testManager.username);
+ });
+
+ it('Should return 200 and promote EVALUATOR to MANAGER', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBeDefined();
+ });
+
+ it('Should return 200 and demote MANAGER to EVALUATOR', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${managerMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'EVALUATOR' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id).toBeDefined();
+ });
+
+ it('Should return 200 and promote EVALUATOR to ADMIN', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'ADMIN' })
+ .expect(200);
+
+ expect(response.body.id).toBeDefined();
+ });
+
+ // Permission errors (403)
+ it('Should return 403 when EVALUATOR tries to update member role', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${regularMember.username}`)
+ .set('x-api-key', evaluatorMember.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when user without org role tries to update member role', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', regularUserNoPermission.apiKey)
+ .send({ role: 'ADMIN' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when MANAGER tries to promote member to ADMIN', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', managerMember.apiKey)
+ .send({ role: 'ADMIN' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when MANAGER tries to update ADMIN member role', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${adminMember.username}`)
+ .set('x-api-key', managerMember.apiKey)
+ .send({ role: 'EVALUATOR' })
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ // Validation errors (422)
+ it('Should return 422 when role field is not provided', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({})
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role field is empty', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: '' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role field is invalid', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'INVALID_ROLE' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when trying to assign OWNER role', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'OWNER' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 with invalid organization ID format', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/invalid-id/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 when role is not a string', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 123 })
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ // Invalid data errors (400)
+ it('Should return 400 when trying to update non-existent member', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/nonexistent_user`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when trying to update member not in organization', async function () {
+ const response = await request(app)
+ .put(
+ `${baseUrl}/organizations/${testOrganization.id}/members/${regularUserNoPermission.username}`
+ )
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ // Not found errors (404)
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${fakeId}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ // Edge cases
+ it('Should return 400 when trying to update organization owner role', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${ownerUser.username}`)
+ .set('x-api-key', spaceAdmin.apiKey)
+ .send({ role: 'ADMIN' })
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 409 when updating same role', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'EVALUATOR' })
+ .expect(409);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when username parameter is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(404);
+ });
+
+ it('Should handle multiple role updates correctly', async function () {
+ // First update
+ await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'MANAGER' })
+ .expect(200);
+
+ // Second update
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorMember.username}`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send({ role: 'ADMIN' })
+ .expect(200);
+
+ expect(response.body.id).toBeDefined();
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId', function () {
+ let testOrganization: LeanOrganization;
+ let spaceAdmin: any;
+ let ownerUser: any;
+ let adminUser: any;
+ let managerUser: any;
+ let evaluatorUser: any;
+ let regularUserNoPermission: any;
+
+ beforeEach(async function () {
+ spaceAdmin = await createTestUser('ADMIN');
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ adminUser = await createTestUser('USER');
+ managerUser = await createTestUser('USER');
+ evaluatorUser = await createTestUser('USER');
+ regularUserNoPermission = await createTestUser('USER');
+
+ // Add owner to organization
+ await addMemberToOrganization(testOrganization.id!, {
+ username: adminUser.username,
+ role: 'ADMIN',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterEach(async function () {
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+
+ if (spaceAdmin?.username) {
+ await deleteTestUser(spaceAdmin.username);
+ }
+
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (managerUser?.username) {
+ await deleteTestUser(managerUser.username);
+ }
+ if (evaluatorUser?.username) {
+ await deleteTestUser(evaluatorUser.username);
+ }
+ if (regularUserNoPermission?.username) {
+ await deleteTestUser(regularUserNoPermission.username);
+ }
+ });
+
+ it('Should return 204 and remove organization with services', async function () {
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', spaceAdmin.apiKey);
+
+ expect(responseDelete.status).toBe(204);
+
+ const responseServices = await request(app)
+ .get(`${baseUrl}/services/`)
+ .set('x-api-key', spaceAdmin.apiKey);
+
+ expect(responseServices.status).toBe(200);
+ expect(
+ responseServices.body.every(
+ (service: any) => service.organizationId !== testOrganization.id
+ )
+ ).toBe(true);
+
+ const organizationFindResponse = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', spaceAdmin.apiKey);
+
+ expect(organizationFindResponse.status).toBe(404);
+ });
+
+ it('Should return 200 and remove member from organization with OWNER request', async function () {
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', ownerUser.apiKey);
+
+ expect(responseDelete.status).toBe(204);
+
+ const responseServices = await request(app)
+ .get(`${baseUrl}/services/`)
+ .set('x-api-key', spaceAdmin.apiKey);
+
+ expect(responseServices.status).toBe(200);
+ expect(
+ responseServices.body.every(
+ (service: any) => service.organizationId !== testOrganization.id
+ )
+ ).toBe(true);
+
+ const organizationFindResponse = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', spaceAdmin.apiKey);
+
+ expect(organizationFindResponse.status).toBe(404);
+ });
+
+ it('Should return 409 when trying to remove default organization', async function () {
+ const defaultOrg = {
+ name: `Default Organization ${Date.now()}`,
+ owner: ownerUser.username,
+ default: true,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/`)
+ .set('x-api-key', spaceAdmin.apiKey)
+ .send(defaultOrg)
+ .expect(201);
+
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${response.body.id}`)
+ .set('x-api-key', spaceAdmin.apiKey);
+
+ expect(responseDelete.status).toBe(409);
+ expect(responseDelete.body.error).toBeDefined();
+ });
+
+ it('Should return 403 with org ADMIN request', async function () {
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', adminUser.apiKey);
+
+ expect(responseDelete.status).toBe(403);
+ expect(responseDelete.body.error).toBeDefined();
+ });
+
+ it('Should return 403 with org MANAGER request', async function () {
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', managerUser.apiKey);
+
+ expect(responseDelete.status).toBe(403);
+ expect(responseDelete.body.error).toBeDefined();
+ });
+
+ it('Should return 403 with org EVALUATOR request', async function () {
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', evaluatorUser.apiKey);
+
+ expect(responseDelete.status).toBe(403);
+ expect(responseDelete.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when user without org role tries to remove member', async function () {
+ const responseDelete = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', regularUserNoPermission.apiKey);
+
+ expect(responseDelete.status).toBe(403);
+ expect(responseDelete.body.error).toBeDefined();
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${fakeId}`)
+ .set('x-api-key', spaceAdmin.apiKey)
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 with invalid organization ID format', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/invalid-id`)
+ .set('x-api-key', spaceAdmin.apiKey)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId/members/:username', function () {
+ let testOrganization: LeanOrganization;
+ let ownerUser: any;
+ let adminUser: any;
+ let managerUser: any;
+ let evaluatorUser: any;
+ let regularUserNoPermission: any;
+
+ beforeEach(async function () {
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ adminUser = await createTestUser('USER');
+ managerUser = await createTestUser('USER');
+ evaluatorUser = await createTestUser('USER');
+ regularUserNoPermission = await createTestUser('USER');
+
+ // Add owner to organization
+ await addMemberToOrganization(testOrganization.id!, {
+ username: adminUser.username,
+ role: 'ADMIN',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterEach(async function () {
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (managerUser?.username) {
+ await deleteTestUser(managerUser.username);
+ }
+ if (evaluatorUser?.username) {
+ await deleteTestUser(evaluatorUser.username);
+ }
+ if (regularUserNoPermission?.username) {
+ await deleteTestUser(regularUserNoPermission.username);
+ }
+ });
+
+ it('Should return 200 and remove member from organization with SPACE admin request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${managerUser.username}`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and remove member from organization with OWNER request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${managerUser.username}`)
+ .set('x-api-key', ownerUser.apiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and remove member from organization with org ADMIN request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${managerUser.username}`)
+ .set('x-api-key', adminUser.apiKey)
+ .expect(200);
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and remove EVALUATOR member from organization with org MANAGER request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${evaluatorUser.username}`)
+ .set('x-api-key', managerUser.apiKey)
+ .expect(200);
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 when EVALUATOR user removes themselves from organization', async function () {
+ const secondEvaluatorUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: secondEvaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${secondEvaluatorUser.username}`)
+ .set('x-api-key', secondEvaluatorUser.apiKey)
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ expect(response.body.members).toBeDefined();
+ expect(response.body.members.some((m: any) => m.username === secondEvaluatorUser.username)).toBe(false);
+
+ await deleteTestUser(secondEvaluatorUser.username);
+ });
+
+ it('Should return 403 when EVALUATOR user tries to remove another member', async function () {
+ const secondEvaluatorUser = await createTestUser('USER');
+ await addMemberToOrganization(testOrganization.id!, {
+ username: secondEvaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+
+ const response = await request(app)
+ .delete(
+ `${baseUrl}/organizations/${testOrganization.id}/members/${regularUserNoPermission.username}`
+ )
+ .set('x-api-key', secondEvaluatorUser.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+
+ await deleteTestUser(secondEvaluatorUser.username);
+ });
+
+ it('Should return 403 when MANAGER user tries to remove ADMIN member', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${adminUser.username}`)
+ .set('x-api-key', managerUser.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when user without org role tries to remove member', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members/${managerUser.username}`)
+ .set('x-api-key', regularUserNoPermission.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when removing non-existent member', async function () {
+ const response = await request(app)
+ .delete(
+ `${baseUrl}/organizations/${testOrganization.id}/members/nonexistent_user_${Date.now()}`
+ )
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 404 when username field is missing', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/members`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(404);
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${fakeId}/members/${managerUser.username}`)
+ .set('x-api-key', adminApiKey)
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 with invalid organization ID format', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/invalid-id/members/${managerUser.username}`)
+ .set('x-api-key', adminApiKey)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ describe('EVALUATOR member restrictions', function () {
+ let evaluatorGroupOrganization: LeanOrganization;
+ let evaluator1User: any;
+ let evaluator2User: any;
+ let managerInGroup: any;
+
+ beforeEach(async function () {
+ managerInGroup = await createTestUser('USER');
+ evaluator1User = await createTestUser('USER');
+ evaluator2User = await createTestUser('USER');
+
+ evaluatorGroupOrganization = await createTestOrganization(managerInGroup.username);
+
+ await addMemberToOrganization(evaluatorGroupOrganization.id!, {
+ username: evaluator1User.username,
+ role: 'EVALUATOR',
+ });
+ await addMemberToOrganization(evaluatorGroupOrganization.id!, {
+ username: evaluator2User.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterEach(async function () {
+ if (evaluatorGroupOrganization?.id) {
+ await deleteTestOrganization(evaluatorGroupOrganization.id);
+ }
+ if (managerInGroup?.username) {
+ await deleteTestUser(managerInGroup.username);
+ }
+ if (evaluator1User?.username) {
+ await deleteTestUser(evaluator1User.username);
+ }
+ if (evaluator2User?.username) {
+ await deleteTestUser(evaluator2User.username);
+ }
+ });
+
+ it('EVALUATOR can only remove themselves from organization', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${evaluatorGroupOrganization.id}/members/${evaluator1User.username}`)
+ .set('x-api-key', evaluator1User.apiKey)
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ expect(response.body.members).toBeDefined();
+ expect(response.body.members.some((m: any) => m.username === evaluator1User.username)).toBe(false);
+ });
+
+ it('EVALUATOR cannot remove another EVALUATOR', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${evaluatorGroupOrganization.id}/members/${evaluator2User.username}`)
+ .set('x-api-key', evaluator1User.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('EVALUATOR cannot remove MANAGER from organization', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${evaluatorGroupOrganization.id}/members/${managerInGroup.username}`)
+ .set('x-api-key', evaluator1User.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('EVALUATOR cannot be removed by another EVALUATOR', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${evaluatorGroupOrganization.id}/members/${evaluator1User.username}`)
+ .set('x-api-key', evaluator2User.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId/api-keys/:apiKey', function () {
+ let testOrganization: LeanOrganization;
+ let ownerUser: any;
+ let adminUser: any;
+ let managerUser: any;
+ let evaluatorUser: any;
+ let regularUserNoPermission: any;
+ let testAllApiKey: string;
+ let testManagementApiKey: string;
+ let testEvaluationApiKey: string;
+
+ beforeEach(async function () {
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ adminUser = await createTestUser('USER');
+ managerUser = await createTestUser('USER');
+ evaluatorUser = await createTestUser('USER');
+ regularUserNoPermission = await createTestUser('USER');
+
+ // Create an API key to delete
+ const allApiKeyData = {
+ key: `org_${crypto.randomBytes(32).toString('hex')}`,
+ scope: 'ALL' as const,
+ };
+
+ const managementApiKeyData = {
+ key: `org_${crypto.randomBytes(32).toString('hex')}`,
+ scope: 'MANAGEMENT' as const,
+ };
+
+ const evaluationApiKeyData = {
+ key: `org_${crypto.randomBytes(32).toString('hex')}`,
+ scope: 'EVALUATION' as const,
+ };
+
+ await addApiKeyToOrganization(testOrganization.id!, allApiKeyData);
+ await addApiKeyToOrganization(testOrganization.id!, managementApiKeyData);
+ await addApiKeyToOrganization(testOrganization.id!, evaluationApiKeyData);
+
+ testAllApiKey = allApiKeyData.key;
+ testManagementApiKey = managementApiKeyData.key;
+ testEvaluationApiKey = evaluationApiKeyData.key;
+
+ // Add members to organization
+ await addMemberToOrganization(testOrganization.id!, {
+ username: adminUser.username,
+ role: 'ADMIN',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: managerUser.username,
+ role: 'MANAGER',
+ });
+ await addMemberToOrganization(testOrganization.id!, {
+ username: evaluatorUser.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterEach(async function () {
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (managerUser?.username) {
+ await deleteTestUser(managerUser.username);
+ }
+ if (evaluatorUser?.username) {
+ await deleteTestUser(evaluatorUser.username);
+ }
+ if (regularUserNoPermission?.username) {
+ await deleteTestUser(regularUserNoPermission.username);
+ }
+ });
+
+ it('Should return 200 and delete API key from organization with SPACE ADMIN request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', adminApiKey)
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and delete API key from organization with organization ADMIN request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', adminUser.apiKey)
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and delete API key from organization with organization MANAGER request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', managerUser.apiKey)
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 200 and delete MANAGEMENT API key from organization with organization MANAGER request', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testManagementApiKey}`)
+ .set('x-api-key', managerUser.apiKey)
+ .expect(200);
+
+ expect(response.body).toBeDefined();
+ });
+
+ it('Should return 403 when user without org role tries to delete API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', regularUserNoPermission.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when MANAGER user tries to delete ALL API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testAllApiKey}`)
+ .set('x-api-key', managerUser.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 403 when EVALUATOR user tries to delete API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', evaluatorUser.apiKey)
+ .expect(403);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 400 when deleting non-existent API key', async function () {
+ const response = await request(app)
+ .delete(
+ `${baseUrl}/organizations/${testOrganization.id}/api-keys/nonexistent_key_${Date.now()}`
+ )
+ .set('x-api-key', adminApiKey)
+ .expect(400);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 404 when apiKey field is missing', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testOrganization.id}/api-keys`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(404);
+ });
+
+ it('Should return 404 when organization does not exist', async function () {
+ const fakeId = '000000000000000000000000';
+
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${fakeId}/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', adminApiKey)
+ .expect(404);
+
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('Should return 422 with invalid organization ID format', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/invalid-id/api-keys/${testEvaluationApiKey}`)
+ .set('x-api-key', adminApiKey)
+ .expect(422);
+
+ expect(response.body.error).toBeDefined();
+ });
+ });
+});
diff --git a/api/src/test/permissions.test.ts b/api/src/test/permissions.test.ts
new file mode 100644
index 0000000..cefcf7e
--- /dev/null
+++ b/api/src/test/permissions.test.ts
@@ -0,0 +1,2644 @@
+import request from 'supertest';
+import { baseUrl, getApp, shutdownApp } from './utils/testApp';
+import { Server } from 'http';
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
+import {
+ createTestOrganization,
+ deleteTestOrganization,
+ addApiKeyToOrganization,
+ addMemberToOrganization,
+} from './utils/organization/organizationTestUtils';
+import { LeanOrganization, LeanApiKey } from '../main/types/models/Organization';
+import { generateOrganizationApiKey } from '../main/utils/users/helpers';
+import { LeanService } from '../main/types/models/Service';
+import { createTestService, deleteTestService } from './utils/services/serviceTestUtils';
+import { generateContract } from './utils/contracts/generators';
+
+describe('Permissions Test Suite', function () {
+ let app: Server;
+ let adminUser: any;
+ let adminApiKey: string;
+ let regularUser: any;
+ let regularUserApiKey: string;
+ let testOrganization: LeanOrganization;
+ let orgApiKey: LeanApiKey;
+
+ beforeAll(async function () {
+ app = await getApp();
+ });
+
+ beforeEach(async function () {
+ // Create an admin user for tests
+ adminUser = await createTestUser('ADMIN');
+ adminApiKey = adminUser.apiKey;
+
+ // Create a regular user for tests
+ regularUser = await createTestUser('USER');
+ regularUserApiKey = regularUser.apiKey;
+
+ // Create a test organization
+ testOrganization = await createTestOrganization(adminUser.username);
+
+ // Add an organization API key
+ if (testOrganization && testOrganization.id) {
+ orgApiKey = {
+ key: generateOrganizationApiKey(),
+ scope: 'ALL',
+ };
+ await addApiKeyToOrganization(testOrganization.id, orgApiKey);
+ }
+ });
+
+ afterEach(async function () {
+ // Clean up the created users and organization
+ if (testOrganization?.id) {
+ await deleteTestOrganization(testOrganization.id!);
+ }
+ if (adminUser?.username) {
+ await deleteTestUser(adminUser.username);
+ }
+ if (regularUser?.username) {
+ await deleteTestUser(regularUser.username);
+ }
+ });
+
+ afterAll(async function () {
+ await shutdownApp();
+ });
+
+ describe('Public Routes', function () {
+ describe('POST /users/authenticate', function () {
+ it('Should return 200 for public authentication endpoint without API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/users/authenticate`)
+ .send({ username: adminUser.username, password: 'password123' });
+
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('POST /users', function () {
+ let createdUser: any;
+
+ afterEach(async function () {
+ if (createdUser?.username) {
+ await deleteTestUser(createdUser.username);
+ createdUser = null;
+ }
+ });
+
+ it('Should return 201 for public user creation endpoint without API key', async function () {
+ const userData = {
+ username: `public_user_${Date.now()}`,
+ password: 'password123',
+ };
+
+ const response = await request(app).post(`${baseUrl}/users`).send(userData);
+
+ expect(response.status).toBe(201);
+ createdUser = response.body;
+ });
+ });
+
+ describe('GET /health', function () {
+ it('Should return 200 for public health check endpoint without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/healthcheck`);
+
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('Events Routes (Public)', function () {
+ it('Should allow GET /events/status without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/events/status`);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should allow POST /events/test-event without API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/events/test-event`)
+ .send({ serviceName: 'test', pricingVersion: 'v1' });
+
+ expect([200, 400, 404]).toContain(response.status);
+ });
+ });
+ });
+
+ describe('User Routes (requiresUser: true)', function () {
+ describe('GET /users', function () {
+ it('Should return 200 with valid ADMIN user API key', async function () {
+ const response = await request(app).get(`${baseUrl}/users`).set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 200 with valid USER user API key with q parameter', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=${regularUser.username}`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 403 with valid USER user API key without q parameter', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/users`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app).get(`${baseUrl}/users`).set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('GET /users/:username', function () {
+ it('Should return 200 with valid ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 200 with valid USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${regularUser.username}`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/users/${adminUser.username}`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('PUT /users/:username', function () {
+ it('Should return appropriate status with valid ADMIN user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', adminApiKey)
+ .send({ password: 'newpassword123' });
+
+ expect([200, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return appropriate status with valid USER user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${regularUser.username}`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ password: 'newpassword123' });
+
+ expect([200, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}`)
+ .send({ password: 'newpassword123' });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', orgApiKey.key)
+ .send({ password: 'newpassword123' });
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('DELETE /users/:username', function () {
+ let userToDelete: any;
+
+ beforeEach(async function () {
+ userToDelete = await createTestUser('USER');
+ });
+
+ afterEach(async function () {
+ if (userToDelete?.username) {
+ try {
+ await deleteTestUser(userToDelete.username);
+ } catch (e) {
+ // User might already be deleted in test
+ }
+ }
+ });
+
+ it('Should allow deletion with valid ADMIN user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/users/${userToDelete.username}`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should allow deletion with valid USER user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/users/${userToDelete.username}`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(`${baseUrl}/users/${userToDelete.username}`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/users/${userToDelete.username}`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('PUT /users/:username/api-key', function () {
+ it('Should allow API key regeneration with valid ADMIN user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}/api-key`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 400]).toContain(response.status);
+ });
+
+ it('Should allow API key regeneration with valid USER user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${regularUser.username}/api-key`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect([200, 400]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).put(`${baseUrl}/users/${adminUser.username}/api-key`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}/api-key`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('PUT /users/:username/role', function () {
+ let testUser: any;
+ let testAdmin: any;
+
+ beforeEach(async function () {
+ testUser = await createTestUser('USER');
+ testAdmin = await createTestUser('ADMIN');
+ });
+
+ afterEach(async function () {
+ if (testUser?.username) {
+ await deleteTestUser(testUser.username);
+ }
+ if (testAdmin?.username) {
+ await deleteTestUser(testAdmin.username);
+ }
+ });
+
+ it('Should allow role change with valid ADMIN user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .set('x-api-key', testAdmin.apiKey)
+ .send({ role: 'USER' });
+
+ expect([200, 400, 403, 422]).toContain(response.status);
+ });
+
+ it('Should allow role change with valid USER user API key', async function () {
+ const testUser2 = await createTestUser('USER');
+
+ const response = await request(app)
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .set('x-api-key', testUser2.apiKey)
+ .send({ role: 'USER' });
+
+ expect([200, 400, 403, 422]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .send({ role: 'USER' });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+ });
+
+ describe('Organization Routes (requiresUser: true)', function () {
+ describe('GET /organizations', function () {
+ it('Should return 200 with valid ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 200 with valid USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/organizations`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('POST /organizations', function () {
+ let createdOrg: any;
+
+ afterEach(async function () {
+ if (createdOrg?._id) {
+ await deleteTestOrganization(createdOrg._id);
+ createdOrg = null;
+ }
+ });
+
+ it('Should return 201 with valid user API key', async function () {
+ const orgData = {
+ name: `test_org_${Date.now()}`,
+ owner: adminUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations`)
+ .set('x-api-key', adminApiKey)
+ .send(orgData);
+
+ if (response.status === 201) {
+ createdOrg = response.body;
+ }
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations`)
+ .send({ name: 'test', owner: adminUser.username });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations`)
+ .set('x-api-key', orgApiKey.key)
+ .send({ name: 'test', owner: adminUser.username });
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('GET /organizations/:organizationId', function () {
+ it('Should return 200 with valid user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/organizations/${testOrganization.id}`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}`)
+ .set('x-api-key', orgApiKey.key)
+ .send({ name: 'test', owner: adminUser.username });
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('Organization-scoped Service Routes', function () {
+ let testServicesOrganization: LeanOrganization;
+ let testServicesOrganizationWithoutMembers: LeanOrganization;
+ let testOwnerUser: any;
+ let testMemberUser: any;
+ let testEvaluatorMemberUser: any;
+ let testNonMemberUser: any;
+
+ beforeAll(async function () {
+ // Create users
+ testOwnerUser = await createTestUser('USER');
+ testMemberUser = await createTestUser('USER');
+ testEvaluatorMemberUser = await createTestUser('USER');
+ testNonMemberUser = await createTestUser('USER');
+
+ // Create organization
+ testServicesOrganization = await createTestOrganization(testOwnerUser.username);
+ testServicesOrganizationWithoutMembers = await createTestOrganization();
+
+ // Add member to organization
+ await addMemberToOrganization(testServicesOrganization.id!, {
+ username: testMemberUser.username,
+ role: 'MANAGER',
+ });
+
+ await addMemberToOrganization(testServicesOrganization.id!, {
+ username: testEvaluatorMemberUser.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterAll(async function () {
+ // Delete organization
+ if (testServicesOrganization?.id) {
+ await deleteTestOrganization(testServicesOrganization.id!);
+ }
+
+ // Delete users
+ if (testOwnerUser?.username) {
+ await deleteTestUser(testOwnerUser.username);
+ }
+ if (testMemberUser?.username) {
+ await deleteTestUser(testMemberUser.username);
+ }
+ if (testNonMemberUser?.username) {
+ await deleteTestUser(testNonMemberUser.username);
+ }
+ });
+
+ describe('GET /organizations/:organizationId/services', function () {
+ it('Should allow access with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid OWNER API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testOwnerUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid MANAGER API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testMemberUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(
+ `${baseUrl}/organizations/${testOrganization.id}/services`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 when not member of request organization', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testServicesOrganizationWithoutMembers.id}/services`)
+ .set('x-api-key', testOwnerUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrganization.id}/services`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('POST /organizations/:organizationId/services', function () {
+ it('Should allow creation with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', adminApiKey)
+ .send({ name: '${testService.name}' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with valid OWNER API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testOwnerUser.apiKey)
+ .send({ name: '${testService.name}' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with valid MANAGER API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testMemberUser.apiKey)
+ .send({ name: '${testService.name}' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with EVALUATOR API key (requires ADMIN or MANAGER)', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testEvaluatorMemberUser.apiKey)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).post(
+ `${baseUrl}/organizations/${testServicesOrganization.id}/services`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', orgApiKey.key)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId/services', function () {
+ it('Should allow deletion with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should allow deletion with valid OWNER API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testOwnerUser.apiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with MANAGER API key (requires ADMIN)', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with EVALUATOR API key (requires ADMIN)', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', testEvaluatorMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(
+ `${baseUrl}/organizations/${testServicesOrganization.id}/services`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testServicesOrganization.id}/services`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+ });
+
+ describe('Organization-scoped Contract Routes', function () {
+ let testContractsOrganization: LeanOrganization;
+ let testContractsOrganizationWithoutMembers: LeanOrganization;
+ let testContractOwnerUser: any;
+ let testContractAdminUser: any;
+ let testContractMemberUser: any;
+ let testContractEvaluatorMemberUser: any;
+ let testContractUser: any;
+
+ beforeAll(async function () {
+ // Create users
+ testContractOwnerUser = await createTestUser('USER');
+ testContractAdminUser = await createTestUser('USER');
+ testContractMemberUser = await createTestUser('USER');
+ testContractEvaluatorMemberUser = await createTestUser('USER');
+ testContractUser = await createTestUser('USER');
+
+ // Create organizations
+ testContractsOrganization = await createTestOrganization(testContractOwnerUser.username);
+ testContractsOrganizationWithoutMembers = await createTestOrganization();
+
+ // Add members to organization
+ await addMemberToOrganization(testContractsOrganization.id!, {
+ username: testContractAdminUser.username,
+ role: 'ADMIN',
+ });
+
+ await addMemberToOrganization(testContractsOrganization.id!, {
+ username: testContractMemberUser.username,
+ role: 'MANAGER',
+ });
+
+ await addMemberToOrganization(testContractsOrganization.id!, {
+ username: testContractEvaluatorMemberUser.username,
+ role: 'EVALUATOR',
+ });
+ });
+
+ afterAll(async function () {
+ // Delete organizations
+ if (testContractsOrganization?.id) {
+ await deleteTestOrganization(testContractsOrganization.id!);
+ }
+ if (testContractsOrganizationWithoutMembers?.id) {
+ await deleteTestOrganization(testContractsOrganizationWithoutMembers.id!);
+ }
+
+ // Delete users
+ if (testContractOwnerUser?.username) {
+ await deleteTestUser(testContractOwnerUser.username);
+ }
+ if (testContractMemberUser?.username) {
+ await deleteTestUser(testContractMemberUser.username);
+ }
+ if (testContractEvaluatorMemberUser?.username) {
+ await deleteTestUser(testContractEvaluatorMemberUser.username);
+ }
+ if (testContractUser?.username) {
+ await deleteTestUser(testContractUser.username);
+ }
+ });
+
+ describe('GET /organizations/:organizationId/contracts', function () {
+ it('Should allow access with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid OWNER API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractOwnerUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid MANAGER API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractMemberUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid EVALUATOR API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractEvaluatorMemberUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(
+ `${baseUrl}/organizations/${testContractsOrganization.id}/contracts`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 when not member of request organization', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganizationWithoutMembers.id}/contracts`)
+ .set('x-api-key', testContractOwnerUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('POST /organizations/:organizationId/contracts', function () {
+ it('Should allow creation with valid SPACE ADMIN API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: testContractUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', adminApiKey)
+ .send(contractData);
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with valid OWNER API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: testContractUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractOwnerUser.apiKey)
+ .send(contractData);
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with valid MANAGER API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: testContractUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractMemberUser.apiKey)
+ .send(contractData);
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with valid EVALUATOR API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: testContractUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractEvaluatorMemberUser.apiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).post(
+ `${baseUrl}/organizations/${testContractsOrganization.id}/contracts`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ subscriptionUser: testContractUser.username,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', orgApiKey.key)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId/contracts', function () {
+ it('Should allow deletion with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should allow deletion with valid OWNER API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractOwnerUser.apiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should allow deletion with valid ADMIN API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractAdminUser.apiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with MANAGER API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with valid EVALUATOR API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', testContractEvaluatorMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(
+ `${baseUrl}/organizations/${testContractsOrganization.id}/contracts`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('GET /organizations/:organizationId/contracts/:userId', function () {
+ it('Should allow access with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid OWNER API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractOwnerUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid MANAGER API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractMemberUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with valid EVALUATOR API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractEvaluatorMemberUser.apiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(
+ `${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('PUT /organizations/:organizationId/contracts/:userId', function () {
+ it('Should allow updates with valid SPACE ADMIN API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', adminApiKey)
+ .send(contractData);
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow updates with valid OWNER API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractOwnerUser.apiKey)
+ .send(contractData);
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow updates with valid MANAGER API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractMemberUser.apiKey)
+ .send(contractData);
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with EVALUATOR API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractEvaluatorMemberUser.apiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).put(
+ `${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const contractData = {
+ subscriptionPlan: 'BASEBOARD',
+ subscriptionAddOns: {},
+ };
+
+ const response = await request(app)
+ .put(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', orgApiKey.key)
+ .send(contractData);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('DELETE /organizations/:organizationId/contracts/:userId', function () {
+ it('Should allow deletion with valid SPACE ADMIN API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should allow deletion with valid OWNER API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractOwnerUser.apiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with MANAGER API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with EVALUATOR API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', testContractEvaluatorMemberUser.apiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(
+ `${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/organizations/${testContractsOrganization.id}/contracts/${testContractUser.username}`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+ });
+ });
+ });
+
+ describe('Service Routes (Organization API Keys)', function () {
+
+ let testServicesOrganization: LeanOrganization;
+ let ownerUser: any;
+ let allApiKey: LeanApiKey;
+ let managementApiKey: LeanApiKey;
+ let evaluationApiKey: LeanApiKey;
+ let testService: LeanService;
+
+ beforeEach(async function () {
+ // Create owner user
+ ownerUser = await createTestUser('USER');
+
+ // Create organization
+ testServicesOrganization = await createTestOrganization(ownerUser.username);
+
+ // Create a test service
+ testService = await createTestService(testServicesOrganization.id!, `test-service_${crypto.randomUUID()}`);
+
+ // Add organization API keys
+ allApiKey = { key: generateOrganizationApiKey(), scope: 'ALL' };
+ managementApiKey = { key: generateOrganizationApiKey(), scope: 'MANAGEMENT' };
+ evaluationApiKey = { key: generateOrganizationApiKey(), scope: 'EVALUATION' };
+
+ await addApiKeyToOrganization(testServicesOrganization.id!, allApiKey);
+ await addApiKeyToOrganization(testServicesOrganization.id!, managementApiKey);
+ await addApiKeyToOrganization(testServicesOrganization.id!, evaluationApiKey);
+ });
+
+ afterEach(async function () {
+ if (testService?.id) {
+ await deleteTestService(testService.name!, testServicesOrganization.id!);
+ }
+
+ // Delete organization
+ if (testServicesOrganization?.id) {
+ await deleteTestOrganization(testServicesOrganization.id!);
+ }
+
+ // Delete owner user
+ if (ownerUser?.username) {
+ await deleteTestUser(ownerUser.username);
+ }
+ });
+
+ describe('GET /services - Organization Role: ALL, MANAGEMENT, EVALUATION', function () {
+ it('Should return 200 with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', allApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', managementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/services`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 200 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .get(`${baseUrl}/services`)
+ .set('x-api-key', testUser.apiKey);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('POST /services - Organization Role: ALL, MANAGEMENT', function () {
+ it('Should allow creation with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', allApiKey.key)
+ .send({ name: '${testService.name}' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', managementApiKey.key)
+ .send({ name: '${testService.name}' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', evaluationApiKey.key)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', adminApiKey)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', testUser.apiKey)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('DELETE /services - Organization Role: ALL', function () {
+ it('Should return 200 with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services`)
+ .set('x-api-key', allApiKey.key);
+
+ expect(response.status).toBe(200);
+
+ testService.id = undefined;
+ });
+
+ it('Should return 200 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+
+ testService.id = undefined;
+ });
+
+ it('Should return 403 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services`)
+ .set('x-api-key', managementApiKey.key);
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', testUser.apiKey)
+ .send({ name: '${testService.name}' });
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('GET /services/:serviceName', function () {
+ it('Should allow access with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', allApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', managementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/services/${testService.name}`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testUser.apiKey);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('PUT /services/:serviceName - Organization Role: ALL, MANAGEMENT', function () {
+ it('Should allow update with organization API key with ALL scope', async function () {
+ const response1 = await request(app)
+ .put(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', allApiKey.key)
+ .send({ name: "Updated-" + testService.name });
+
+ expect([200, 400, 404, 422]).toContain(response1.status);
+
+ const response2 = await request(app)
+ .put(`${baseUrl}/services/${"Updated-" + testService.name}`)
+ .set('x-api-key', allApiKey.key)
+ .send({ name: testService.name });
+
+ expect([200, 400, 404, 422, 409]).toContain(response2.status);
+ });
+
+ it('Should allow update with organization API key with MANAGEMENT scope', async function () {
+ const response1 = await request(app)
+ .put(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', managementApiKey.key)
+ .send({ name: 'Updated-' + testService.name });
+
+ expect([200, 400, 404, 422]).toContain(response1.status);
+
+ const response2 = await request(app)
+ .put(`${baseUrl}/services/${'Updated-' + testService.name}`)
+ .set('x-api-key', managementApiKey.key)
+ .send({ name: testService.name });
+
+ expect([200, 400, 404, 422, 409]).toContain(response2.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', evaluationApiKey.key)
+ .send({ name: 'Updated service' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}`)
+ .send({ name: 'Updated service' });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testUser.apiKey)
+ .send({ name: 'Updated service' });
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('DELETE /services/:serviceName - Organization Role: ALL', function () {
+ it('Should allow deletion with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', allApiKey.key);
+
+ expect([200, 204, 404]).toContain(response.status);
+
+ testService.id = undefined;
+ });
+
+ it('Should return 403 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', managementApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(`${baseUrl}/services/${testService.name}`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testUser.apiKey);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('Service Pricings Routes', function () {
+ describe('GET /services/:serviceName/pricings', function () {
+ it('Should allow access with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', allApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', managementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/services/${testService.name}/pricings`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', testUser.apiKey);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('POST /services/:serviceName/pricings', function () {
+ it('Should allow creation with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', allApiKey.key)
+ .send({ version: 'v1' });
+
+ expect([201, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', managementApiKey.key)
+ .send({ version: 'v1' });
+
+ expect([201, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', evaluationApiKey.key)
+ .send({ version: 'v1' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).post(`${baseUrl}/services/${testService.name}/pricings`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', adminApiKey)
+ .send({ version: 'v1' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+
+ const response = await request(app)
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', testUser.apiKey)
+ .send({ version: 'v1' });
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('GET /services/:serviceName/pricings/:pricingVersion', function () {
+ it('Should allow access with organization API key with ALL scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0]
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', allApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with MANAGEMENT scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0]
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', managementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with EVALUATION scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0]
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0]
+
+ const response = await request(app).get(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0]
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .get(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', testUser.apiKey);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('PUT /services/:serviceName/pricings/:pricingVersion', function () {
+ it('Should allow update with organization API key with ALL scope', async function () {
+ const testPricingId = testService.activePricings.keys().next().value!;
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', allApiKey.key)
+ .send({ available: true });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow update with organization API key with MANAGEMENT scope', async function () {
+ const testPricingId = testService.activePricings.keys().next().value!;
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', managementApiKey.key)
+ .send({ available: true });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', evaluationApiKey.key)
+ .send({ available: true });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .send({ available: true });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', adminApiKey)
+ .send({ available: true });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .put(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', testUser.apiKey)
+ .send({ available: true });
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+
+ describe('DELETE /services/:serviceName/pricings/:pricingVersion', function () {
+ it('Should allow deletion with organization API key with ALL scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', allApiKey.key);
+
+ expect([200, 204, 404, 409]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with MANAGEMENT scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', managementApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', evaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app).delete(
+ `${baseUrl}/services/${testService.name}/pricings/${testPricingId}`
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with ADMIN user API key', async function () {
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER API key (requires org key)', async function () {
+ const testUser = await createTestUser('USER');
+ const testPricingId = Object.keys(testService.activePricings!)[0];
+
+ const response = await request(app)
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${testPricingId}`)
+ .set('x-api-key', testUser.apiKey);
+
+ expect(response.status).toBe(403);
+
+ await deleteTestUser(testUser.username);
+ });
+ });
+ });
+ });
+
+ describe('Contract Routes (ADMIN, USER with Org Roles)', function () {
+ let contractTestOrganization: LeanOrganization;
+ let contractOwnerUser: any;
+ let contractAllApiKey: LeanApiKey;
+ let contractManagementApiKey: LeanApiKey;
+ let contractEvaluationApiKey: LeanApiKey;
+
+ beforeAll(async function () {
+ // Create owner user for organization
+ contractOwnerUser = await createTestUser('USER');
+
+ // Create organization
+ contractTestOrganization = await createTestOrganization(contractOwnerUser.username);
+
+ // Add organization API keys
+ contractAllApiKey = { key: generateOrganizationApiKey(), scope: 'ALL' };
+ contractManagementApiKey = { key: generateOrganizationApiKey(), scope: 'MANAGEMENT' };
+ contractEvaluationApiKey = { key: generateOrganizationApiKey(), scope: 'EVALUATION' };
+
+ await addApiKeyToOrganization(contractTestOrganization.id!, contractAllApiKey);
+ await addApiKeyToOrganization(contractTestOrganization.id!, contractManagementApiKey);
+ await addApiKeyToOrganization(contractTestOrganization.id!, contractEvaluationApiKey);
+ });
+
+ afterAll(async function () {
+ // Delete organization
+ if (contractTestOrganization?.id) {
+ await deleteTestOrganization(contractTestOrganization.id!);
+ }
+
+ // Delete owner user
+ if (contractOwnerUser?.username) {
+ await deleteTestUser(contractOwnerUser.username);
+ }
+ });
+
+ describe('GET /contracts - Org Role: ALL, MANAGEMENT', function () {
+ it('Should return 200 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 200 with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', contractAllApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', contractManagementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts`)
+ .set('x-api-key', contractEvaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/contracts`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('POST /contracts - Org Role: ALL, MANAGEMENT', function () {
+ it('Should return 201 creation with ADMIN user API key', async function () {
+ const ownerUser = await createTestUser('USER');
+ const testOrg = await createTestOrganization(ownerUser.username);
+ const testService = await createTestService(testOrg.id!)
+
+ let contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrg.id!,
+ undefined,
+ app
+ );
+
+ contractData = {
+ ...contractData,
+ organizationId: testOrg.id!,
+ };
+
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', adminApiKey)
+ .send(contractData);
+
+ expect(response.status).toBe(201);
+ });
+
+ it('Should return 403 creation with USER user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ userId: 'test-user' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow creation with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', contractAllApiKey.key)
+ .send({ userId: 'test-user' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should allow creation with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', contractManagementApiKey.key)
+ .send({ userId: 'test-user' });
+
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', contractEvaluationApiKey.key)
+ .send({ userId: 'test-user' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).post(`${baseUrl}/contracts`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('GET /contracts/:userId', function () {
+ it('Should return 200 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 200 with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractAllApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractManagementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractEvaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/contracts/test-user`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('PUT /contracts/:userId', function () {
+ it('Should allow update with ADMIN user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', adminApiKey)
+ .send({ serviceName: 'test' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ serviceName: 'test' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow update with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractAllApiKey.key)
+ .send({ serviceName: 'test' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow update with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractManagementApiKey.key)
+ .send({ serviceName: 'test' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractEvaluationApiKey.key)
+ .send({ serviceName: 'test' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).put(`${baseUrl}/contracts/test-user`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('DELETE /contracts/:userId - Org Role: ALL', function () {
+ it('Should allow deletion with ADMIN user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow deletion with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractAllApiKey.key);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractManagementApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/contracts/test-user`)
+ .set('x-api-key', contractEvaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(`${baseUrl}/contracts/test-user`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+ });
+
+ describe('Feature Evaluation Routes', function () {
+ let featureTestOrganization: LeanOrganization;
+ let featureOwnerUser: any;
+ let featureAllApiKey: LeanApiKey;
+ let featureManagementApiKey: LeanApiKey;
+ let featureEvaluationApiKey: LeanApiKey;
+
+ beforeAll(async function () {
+ // Create owner user for organization
+ featureOwnerUser = await createTestUser('USER');
+
+ // Create organization
+ featureTestOrganization = await createTestOrganization(featureOwnerUser.username);
+
+ // Add organization API keys
+ featureAllApiKey = { key: generateOrganizationApiKey(), scope: 'ALL' };
+ featureManagementApiKey = { key: generateOrganizationApiKey(), scope: 'MANAGEMENT' };
+ featureEvaluationApiKey = { key: generateOrganizationApiKey(), scope: 'EVALUATION' };
+
+ await addApiKeyToOrganization(featureTestOrganization.id!, featureAllApiKey);
+ await addApiKeyToOrganization(featureTestOrganization.id!, featureManagementApiKey);
+ await addApiKeyToOrganization(featureTestOrganization.id!, featureEvaluationApiKey);
+ });
+
+ afterAll(async function () {
+ // Delete organization
+ if (featureTestOrganization?.id) {
+ await deleteTestOrganization(featureTestOrganization.id!);
+ }
+
+ // Delete owner user
+ if (featureOwnerUser?.username) {
+ await deleteTestUser(featureOwnerUser.username);
+ }
+ });
+
+ describe('GET /features - User Role: ADMIN, USER | Org Role: ALL, MANAGEMENT, EVALUATION', function () {
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/features`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/features`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 200 with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/features`)
+ .set('x-api-key', featureAllApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/features`)
+ .set('x-api-key', featureManagementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/features`)
+ .set('x-api-key', featureEvaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/features`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('POST /features/evaluate - Org Role: ALL, MANAGEMENT, EVALUATION only', function () {
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/evaluate`)
+ .set('x-api-key', adminApiKey)
+ .send({ userId: 'test-user' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/evaluate`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ userId: 'test-user' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow evaluation with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/evaluate`)
+ .set('x-api-key', featureAllApiKey.key)
+ .send({ userId: 'test-user' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow evaluation with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/evaluate`)
+ .set('x-api-key', featureManagementApiKey.key)
+ .send({ userId: 'test-user' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow evaluation with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/evaluate`)
+ .set('x-api-key', featureEvaluationApiKey.key)
+ .send({ userId: 'test-user' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/evaluate`)
+ .send({ userId: 'test-user' });
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('POST /features/:userId - Org Role: ALL, MANAGEMENT only', function () {
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/test-user`)
+ .set('x-api-key', adminApiKey)
+ .send({ features: [] });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/test-user`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ features: [] });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/test-user`)
+ .set('x-api-key', featureAllApiKey.key)
+ .send({ features: [] });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/test-user`)
+ .set('x-api-key', featureManagementApiKey.key)
+ .send({ features: [] });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/test-user`)
+ .set('x-api-key', featureEvaluationApiKey.key)
+ .send({ features: [] });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/features/test-user`)
+ .send({ features: [] });
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('PUT /features - Org Role: ALL, MANAGEMENT only', function () {
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/features`)
+ .set('x-api-key', adminApiKey)
+ .send({ feature: 'test' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/features`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ feature: 'test' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow update with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/features`)
+ .set('x-api-key', featureAllApiKey.key)
+ .send({ feature: 'test' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should allow update with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/features`)
+ .set('x-api-key', featureManagementApiKey.key)
+ .send({ feature: 'test' });
+
+ expect([200, 400, 404, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/features`)
+ .set('x-api-key', featureEvaluationApiKey.key)
+ .send({ feature: 'test' });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/features`)
+ .send({ feature: 'test' });
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('DELETE /features - Org Role: ALL, MANAGEMENT only', function () {
+ it('Should return 403 with ADMIN user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/features`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 403 with USER user API key', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/features`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should allow deletion with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/features`)
+ .set('x-api-key', featureAllApiKey.key);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should allow deletion with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/features`)
+ .set('x-api-key', featureManagementApiKey.key);
+
+ expect([200, 204, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/features`)
+ .set('x-api-key', featureEvaluationApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).delete(`${baseUrl}/features`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+ });
+
+ describe('Analytics Routes - Org Role: ALL, MANAGEMENT', function () {
+ let analyticsTestOrganization: LeanOrganization;
+ let analyticsOwnerUser: any;
+ let analyticsAllApiKey: LeanApiKey;
+ let analyticsManagementApiKey: LeanApiKey;
+ let analyticsEvaluationApiKey: LeanApiKey;
+
+ beforeAll(async function () {
+ analyticsOwnerUser = await createTestUser('USER');
+ analyticsTestOrganization = await createTestOrganization(analyticsOwnerUser.username);
+
+ analyticsAllApiKey = { key: generateOrganizationApiKey(), scope: 'ALL' };
+ analyticsManagementApiKey = { key: generateOrganizationApiKey(), scope: 'MANAGEMENT' };
+ analyticsEvaluationApiKey = { key: generateOrganizationApiKey(), scope: 'EVALUATION' };
+
+ await addApiKeyToOrganization(analyticsTestOrganization.id!, analyticsAllApiKey);
+ await addApiKeyToOrganization(analyticsTestOrganization.id!, analyticsManagementApiKey);
+ await addApiKeyToOrganization(analyticsTestOrganization.id!, analyticsEvaluationApiKey);
+ });
+
+ afterAll(async function () {
+ if (analyticsTestOrganization?.id) {
+ await deleteTestOrganization(analyticsTestOrganization.id!);
+ }
+
+ if (analyticsOwnerUser?.username) {
+ await deleteTestUser(analyticsOwnerUser.username);
+ }
+ });
+
+ describe('GET /analytics/api-calls', function () {
+ it('Should allow access with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/api-calls`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/api-calls`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/api-calls`)
+ .set('x-api-key', analyticsAllApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/api-calls`)
+ .set('x-api-key', analyticsManagementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/api-calls`)
+ .set('x-api-key', analyticsEvaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/analytics/api-calls`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('GET /analytics/evaluations', function () {
+ it('Should allow access with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/evaluations`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with USER user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/evaluations`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with ALL scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/evaluations`)
+ .set('x-api-key', analyticsAllApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should allow access with organization API key with MANAGEMENT scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/evaluations`)
+ .set('x-api-key', analyticsManagementApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 200 with organization API key with EVALUATION scope', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/analytics/evaluations`)
+ .set('x-api-key', analyticsEvaluationApiKey.key);
+
+ expect([200, 404]).toContain(response.status);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/analytics/evaluations`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+ });
+
+ describe('Cache Routes - ADMIN only', function () {
+ describe('GET /cache/get', function () {
+ it('Should allow access with ADMIN user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/cache/get?key=test`)
+ .set('x-api-key', adminApiKey);
+
+ expect([200, 400, 404]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/cache/get?key=test`)
+ .set('x-api-key', orgApiKey.key);
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).get(`${baseUrl}/cache/get?key=test`);
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with non-admin user API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/cache/get?key=test`)
+ .set('x-api-key', regularUserApiKey);
+
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('POST /cache/set', function () {
+ it('Should allow access with ADMIN user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/cache/set`)
+ .set('x-api-key', adminApiKey)
+ .send({ key: 'test', value: 'test', expirationInSeconds: 60 });
+
+ expect([200, 201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should return 403 with organization API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/cache/set`)
+ .set('x-api-key', orgApiKey.key)
+ .send({ key: 'test', value: 'test', expirationInSeconds: 60 });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('Should return 401 without API key', async function () {
+ const response = await request(app).post(`${baseUrl}/cache/set`).send({ key: 'test', value: 'test', expirationInSeconds: 60 });
+
+ expect(response.status).toBe(401);
+ });
+
+ it('Should return 403 with non-admin user API key', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/cache/set`)
+ .set('x-api-key', regularUserApiKey)
+ .send({ key: 'test', value: 'test', expirationInSeconds: 60 });
+
+ expect(response.status).toBe(403);
+ });
+ });
+ });
+
+ describe('Edge Cases and Invalid Requests', function () {
+ it('Should return 401 with invalid API key format', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', 'invalid-key-123');
+
+ expect([401, 403]).toContain(response.status);
+ });
+
+ it('Should return 401 with expired or non-existent API key', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', 'non_existent_key_12345678901234567890');
+
+ expect([401, 403]).toContain(response.status);
+ });
+
+ it('Should handle requests with missing required headers', async function () {
+ const response = await request(app).post(`${baseUrl}/users`).send({ username: 'test' });
+
+ // Public route should not require API key
+ expect([201, 400, 422]).toContain(response.status);
+ });
+
+ it('Should reject protected route without authentication', async function () {
+ const response = await request(app).get(`${baseUrl}/users`);
+
+ expect(response.status).toBe(401);
+ });
+ });
+});
diff --git a/api/src/test/service.disable.test.ts b/api/src/test/service.disable.test.ts
index b7aff94..00dd23c 100644
--- a/api/src/test/service.disable.test.ts
+++ b/api/src/test/service.disable.test.ts
@@ -1,27 +1,57 @@
import request from 'supertest';
+import fs from 'fs';
import { baseUrl, getApp, shutdownApp } from './utils/testApp';
import { Server } from 'http';
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import {
- createRandomService,
+ createTestService,
+ deleteTestService,
getRandomPricingFile,
- getService,
-} from './utils/services/service';
-import { generatePricingFile } from './utils/services/pricing';
+} from './utils/services/serviceTestUtils';
+import { generatePricingFile } from './utils/services/pricingTestUtils';
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
+import { createTestOrganization, deleteTestOrganization, addApiKeyToOrganization } from './utils/organization/organizationTestUtils';
+import { generateOrganizationApiKey } from '../main/utils/users/helpers';
+import { LeanUser } from '../main/types/models/User';
+import { LeanOrganization } from '../main/types/models/Organization';
+import { LeanService } from '../main/types/models/Service';
+import nock from 'nock';
describe('Service disable / re-enable flow', function () {
let app: Server;
- let adminApiKey: string;
+ let adminUser: LeanUser;
+ let ownerUser: LeanUser;
+ let testOrganization: LeanOrganization;
+ let testService: LeanService;
+ let testApiKey: string;
beforeAll(async function () {
app = await getApp();
- // get admin user and api key helper from existing tests
- const { getTestAdminApiKey, getTestAdminUser, cleanupAuthResources } = await import(
- './utils/auth'
- );
- await getTestAdminUser();
- adminApiKey = await getTestAdminApiKey();
- // note: cleanup will be handled by global test teardown in other suites
+ });
+
+ beforeEach(async function () {
+ adminUser = await createTestUser('ADMIN');
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ testService = await createTestService(testOrganization.id);
+
+ testApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(testOrganization.id!, {key: testApiKey, scope: 'ALL'});
+ });
+
+ afterEach(async function () {
+ if (testService.id) {
+ await deleteTestService(testService.name, testOrganization.id!);
+ }
+ if (testOrganization.id) {
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (adminUser.id) {
+ await deleteTestUser(adminUser.id);
+ }
+ if (ownerUser.id) {
+ await deleteTestUser(ownerUser.id);
+ }
});
afterAll(async function () {
@@ -29,47 +59,46 @@ describe('Service disable / re-enable flow', function () {
});
it('disables a service by moving activePricings to archivedPricings without throwing', async function () {
- const svc = await createRandomService(app);
- // ensure service exists
- const before = await getService(svc.name, app);
- expect(before).toBeDefined();
- expect(before.activePricings && Object.keys(before.activePricings).length).toBeGreaterThan(0);
+ // ensure service exists and has active pricings
+ expect(testService).toBeDefined();
+ expect(testService.activePricings.size).toBeGreaterThan(0);
// disable
const resDisable = await request(app)
- .delete(`${baseUrl}/services/${svc.name}`)
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testApiKey);
- // Controller returns 204 No Content when successful
- expect(resDisable.status).toBe(204);
+ // Controller returns 204 No Content when successful
+ expect(resDisable.status).toBe(204);
// fetch service directly from repository (disabled services are not returned by GET)
const ServiceRepository = (await import('../main/repositories/mongoose/ServiceRepository')).default;
const repo = new ServiceRepository();
- const svcFromRepo = await repo.findByName(svc.name, true);
- expect(svcFromRepo).toBeDefined();
- // type assertion to access disabled flag that might not be present in LeanService type
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- expect((svcFromRepo as any).disabled).toBeTruthy();
- const activeKeys = (svcFromRepo as any).activePricings ? Object.keys((svcFromRepo as any).activePricings) : [];
- const archivedKeys = (svcFromRepo as any).archivedPricings ? Object.keys((svcFromRepo as any).archivedPricings) : [];
- expect(activeKeys.length).toBe(0);
- expect(archivedKeys.length).toBeGreaterThan(0);
+ const svcFromRepo = await repo.findByName(testService.name, testOrganization.id!, true);
+
+ expect(svcFromRepo).toBeDefined();
+ // type assertion to access disabled flag that might not be present in LeanService type
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect((svcFromRepo as any).disabled).toBeTruthy();
+ expect((svcFromRepo as any).activePricings.size).toBe(0);
+ expect((svcFromRepo as any).archivedPricings.size).toBeGreaterThan(0);
+
+ testService.id = undefined;
});
it('re-enables a disabled service when uploading a pricing file with same saasName', async function () {
- const svc = await createRandomService(app);
-
// disable service
- await request(app).delete(`${baseUrl}/services/${svc.name}`).set('x-api-key', adminApiKey);
+ await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testApiKey);
// generate a pricing file with same service name
- const pricingFile = await generatePricingFile(svc.name);
+ const pricingFile = await generatePricingFile(testService.name);
const resCreate = await request(app)
.post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.attach('pricing', pricingFile);
expect(resCreate.status).toBe(201);
@@ -84,17 +113,24 @@ describe('Service disable / re-enable flow', function () {
});
it('re-enables a disabled service when uploading a pricing via URL', async function () {
- const svc = await createRandomService(app);
-
// disable service
- await request(app).delete(`${baseUrl}/services/${svc.name}`).set('x-api-key', adminApiKey);
+ await request(app)
+ .delete(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testApiKey);
+
+ const newPricingVersionPath = await getRandomPricingFile(testService.name);
+ const newPricingVersion = fs.readFileSync(newPricingVersionPath, 'utf-8');
+
+ nock('https://test-domain.com')
+ .get('/test-pricing.yaml')
+ .reply(200, newPricingVersion);
// use a known remote pricing URL (the project tests already use some public urls)
- const pricingUrl = 'https://sphere.score.us.es/static/collections/63f74bf8eeed64058364b52e/IEEE TSC 2025/zoom/2025.yml';
+ const pricingUrl = 'https://test-domain.com/test-pricing.yaml';
const resCreate = await request(app)
.post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.send({ pricing: pricingUrl });
expect(resCreate.status).toBe(201);
diff --git a/api/src/test/service.test.ts b/api/src/test/service.test.ts
index 8b775da..7a25370 100644
--- a/api/src/test/service.test.ts
+++ b/api/src/test/service.test.ts
@@ -1,45 +1,80 @@
+import fs from 'fs';
import request from 'supertest';
import { baseUrl, getApp, shutdownApp } from './utils/testApp';
import { Server } from 'http';
-import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
import {
+ addArchivedPricingToService,
+ addPricingToService,
archivePricingFromService,
- createRandomService,
- deletePricingFromService,
+ createTestService,
+ deleteTestService,
getRandomPricingFile,
getService,
-} from './utils/services/service';
-import { zoomPricingPath } from './utils/services/ServiceTestData';
+ createMultipleTestServices,
+ getPricingFromService,
+} from './utils/services/serviceTestUtils';
import { retrievePricingFromPath } from 'pricing4ts/server';
-import { ExpectedPricingType } from '../main/types/models/Pricing';
-import { TestContract } from './types/models/Contract';
-import { createRandomContract, createRandomContractsForService } from './utils/contracts/contracts';
+import { ExpectedPricingType, LeanUsageLimit } from '../main/types/models/Pricing';
+import { createTestContract, deleteTestContract, getAllContracts, getContractByUserId } from './utils/contracts/contractTestUtils';
+import { generateContract } from './utils/contracts/generators';
+import { LeanContract } from '../main/types/models/Contract';
import { isSubscriptionValid } from '../main/controllers/validation/ContractValidation';
-import { cleanupAuthResources, getTestAdminApiKey, getTestAdminUser } from './utils/auth';
-import { generatePricingFile } from './utils/services/pricing';
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
+import { LeanService } from '../main/types/models/Service';
+import { LeanOrganization } from '../main/types/models/Organization';
+import { LeanUser } from '../main/types/models/User';
+import { addApiKeyToOrganization, createTestOrganization, deleteTestOrganization } from './utils/organization/organizationTestUtils';
+import { generateOrganizationApiKey } from '../main/utils/users/helpers';
+import nock from 'nock';
+import { getFirstPlanFromPricing, getVersionFromPricing } from './utils/regex';
describe('Services API Test Suite', function () {
let app: Server;
- let adminApiKey: string;
-
- const testService = 'Zoom';
+ let adminUser: LeanUser;
+ let ownerUser: LeanUser;
+ let testService: LeanService;
+ let testOrganization: LeanOrganization;
+ let testApiKey: string;
beforeAll(async function () {
app = await getApp();
- // Get admin user and api key for testing
- await getTestAdminUser();
- adminApiKey = await getTestAdminApiKey();
+ });
+
+ beforeEach(async function () {
+ adminUser = await createTestUser('ADMIN');
+ ownerUser = await createTestUser('USER');
+ testOrganization = await createTestOrganization(ownerUser.username);
+ testService = await createTestService(testOrganization.id);
+
+ testApiKey = generateOrganizationApiKey();
+
+ await addApiKeyToOrganization(testOrganization.id!, {key: testApiKey, scope: 'ALL'})
+
+ });
+
+ afterEach(async function () {
+ if (testService.id){
+ await deleteTestService(testService.name, testOrganization.id!);
+ }
+ if (testOrganization.id){
+ await deleteTestOrganization(testOrganization.id);
+ }
+ if (adminUser.id){
+ await deleteTestUser(adminUser.id);
+ }
+ if (ownerUser.id){
+ await deleteTestUser(ownerUser.id);
+ }
});
afterAll(async function () {
- // Cleanup authentication resources
- await cleanupAuthResources();
await shutdownApp();
});
describe('GET /services', function () {
it('Should return 200 and the services', async function () {
- const response = await request(app).get(`${baseUrl}/services`).set('x-api-key', adminApiKey);
+ const response = await request(app).get(`${baseUrl}/services`).set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(response.body).toBeDefined();
expect(Array.isArray(response.body)).toBe(true);
@@ -52,7 +87,7 @@ describe('Services API Test Suite', function () {
const pricingFilePath = await getRandomPricingFile(new Date().getTime().toString());
const response = await request(app)
.post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.attach('pricing', pricingFilePath);
expect(response.status).toEqual(201);
expect(response.body).toBeDefined();
@@ -65,7 +100,7 @@ describe('Services API Test Suite', function () {
it('Should return 201 and the created service: Given url in the request', async function () {
const response = await request(app)
.post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.send({
pricing:
'https://sphere.score.us.es/static/collections/63f74bf8eeed64058364b52e/IEEE TSC 2025/notion/2025.yml',
@@ -83,7 +118,7 @@ describe('Services API Test Suite', function () {
const pricingFilePath = await getRandomPricingFile(new Date().getTime().toString());
const first = await request(app)
.post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.attach('pricing', pricingFilePath);
expect(first.status).toEqual(201);
@@ -91,7 +126,7 @@ describe('Services API Test Suite', function () {
// attempt to create another service with the same pricing (and thus same saasName)
const second = await request(app)
.post(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.attach('pricing', pricingFilePath);
// It must be a 4xx error (client error), not 5xx
@@ -105,108 +140,477 @@ describe('Services API Test Suite', function () {
// Error message should mention existence/duplication
expect(['exists', 'already', 'duplicate'].some(k => errMsg.includes(k))).toBeTruthy();
});
+
+ it('Should return 201 when creating a service with the same name in a different organization', async function () {
+ // Create initial service in first organization
+ const pricingFilePath = await getRandomPricingFile(new Date().getTime().toString());
+ const first = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', testApiKey)
+ .attach('pricing', pricingFilePath);
+
+ expect(first.status).toEqual(201);
+ const firstServiceName = first.body.name;
+
+ // Create a new organization
+ const secondOrganization = await createTestOrganization(ownerUser.username);
+ const secondOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(secondOrganization.id!, {
+ key: secondOrgApiKey,
+ scope: 'ALL',
+ });
+
+ // Attempt to create another service with the same name but in a different organization
+ const second = await request(app)
+ .post(`${baseUrl}/services`)
+ .set('x-api-key', secondOrgApiKey)
+ .attach('pricing', pricingFilePath);
+
+ // Should succeed (201) since it's in a different organization
+ expect(second.status).toEqual(201);
+ expect(second.body).toBeDefined();
+ expect(second.body.name).toEqual(firstServiceName);
+ expect(second.body.organizationId).toEqual(secondOrganization.id);
+ expect(first.body.organizationId).not.toEqual(second.body.organizationId);
+
+ // Cleanup
+ await deleteTestService(firstServiceName, testOrganization.id!);
+ await deleteTestService(second.body.name, secondOrganization.id!);
+ await deleteTestOrganization(secondOrganization.id!);
+ });
});
describe('GET /services/{serviceName}', function () {
it('Should return 200: Given existent service name in lower case', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService.toLowerCase()}`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(Array.isArray(response.body)).toBe(false);
- expect(response.body.name.toLowerCase()).toBe('zoom');
+ expect(response.body.name.toLowerCase()).toBe(testService.name.toLowerCase());
});
it('Should return 200: Given existent service name in upper case', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService.toUpperCase()}`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name.toUpperCase()}`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(Array.isArray(response.body)).toBe(false);
- expect(response.body.name.toLowerCase()).toBe(testService.toLowerCase());
+ expect(response.body.name.toLowerCase()).toBe(testService.name.toLowerCase());
});
it('Should return 404 due to service not found', async function () {
const response = await request(app)
.get(`${baseUrl}/services/unexistent-service`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(404);
- expect(response.body.error).toBe('Service unexistent-service not found');
+ expect(response.body.error).toBe('NOT FOUND: Service with name unexistent-service');
});
});
describe('PUT /services/{serviceName}', function () {
+ let contractsToCleanup: Set = new Set();
+
afterEach(async function () {
+ // Reset service name if it was changed
await request(app)
- .put(`${baseUrl}/services/${testService.toLowerCase()}`)
- .set('x-api-key', adminApiKey)
- .send({ name: testService });
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ name: testService.name })
+ .catch(() => {}); // Ignore errors if service was deleted
+
+ // Cleanup contracts
+ for (const userId of contractsToCleanup) {
+ await deleteTestContract(userId, app);
+ }
+ contractsToCleanup.clear();
});
- it('Should return 200 and the updated pricing', async function () {
- const newName = 'new name for service';
+ // ======== POSITIVE TESTS ========
- const serviceBeforeUpdate = await getService(testService, app);
- expect(serviceBeforeUpdate.name.toLowerCase()).toBe(testService.toLowerCase());
+ it('Should return 200 and change service name without contracts', async function () {
+ const newName = 'updated-service-name-' + Date.now();
+
+ const serviceBeforeUpdate = await getService(testOrganization.id!, testService.name, app);
+ expect(serviceBeforeUpdate.name.toLowerCase()).toBe(testService.name.toLowerCase());
const responseUpdate = await request(app)
- .put(`${baseUrl}/services/${testService}`)
- .set('x-api-key', adminApiKey)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
.send({ name: newName });
+
expect(responseUpdate.status).toEqual(200);
expect(responseUpdate.body).toBeDefined();
expect(responseUpdate.body.name).toEqual(newName);
+ expect(responseUpdate.body.organizationId).toEqual(testOrganization.id);
- await request(app)
- .put(`${baseUrl}/services/${responseUpdate.body.name}`)
- .set('x-api-key', adminApiKey)
- .send({ name: testService });
+ // Verify service was renamed
+ const serviceAfterUpdate = await getService(testOrganization.id!, newName, app);
+ expect(serviceAfterUpdate.name).toEqual(newName);
- const serviceAfterUpdate = await getService(testService, app);
- expect(serviceAfterUpdate.name.toLowerCase()).toBe(testService.toLowerCase());
+ // Update testService reference for cleanup
+ testService.name = newName;
});
- });
- describe('DELETE /services/{serviceName}', function () {
- it('Should return 204', async function () {
- const newContract = await createRandomContract();
- const serviceName = Object.keys(newContract.contractedServices)[0];
+ it('Should return 200 and update service name in existing contracts', async function () {
+ const newServiceName = 'renamed-service-' + Date.now();
+
+ // Create a contract that uses this service
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+ const createResponse = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(contractData);
+
+ expect(createResponse.status).toEqual(201);
+ const contractBefore = createResponse.body as LeanContract;
+ contractsToCleanup.add(contractBefore.userContact.userId);
+
+ // Verify contract has the service
+ expect(contractBefore.contractedServices).toHaveProperty(testService.name.toLowerCase());
+
+ // Update service name
+ const updateResponse = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ name: newServiceName });
+
+ expect(updateResponse.status).toEqual(200);
+ expect(updateResponse.body.name).toEqual(newServiceName);
+
+ // Verify contract was updated with new service name
+ const contractAfter = await getContractByUserId(contractBefore.userContact.userId, app);
+ expect(contractAfter.contractedServices).toHaveProperty(newServiceName.toLowerCase());
+ expect(contractAfter.contractedServices).not.toHaveProperty(testService.name.toLowerCase());
+ expect(contractAfter.subscriptionPlans).toHaveProperty(newServiceName.toLowerCase());
+ expect(contractAfter.subscriptionPlans).not.toHaveProperty(testService.name.toLowerCase());
+
+ // Update testService reference for cleanup
+ testService.name = newServiceName;
+ });
+
+ it('Should return 200 and change service organization (no contracts)', async function () {
+ const newOrganization = await createTestOrganization(ownerUser.username);
+ const newOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(newOrganization.id!, { key: newOrgApiKey, scope: 'ALL' });
+
+ const serviceBeforeUpdate = await getService(testOrganization.id!, testService.name, app);
+ expect(serviceBeforeUpdate.organizationId).toEqual(testOrganization.id);
+
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ organizationId: newOrganization.id });
- const responseBefore = await request(app)
- .get(`${baseUrl}/services/${serviceName}`)
- .set('x-api-key', adminApiKey);
- expect(responseBefore.status).toEqual(200);
- expect(responseBefore.body.name.toLowerCase()).toBe(serviceName.toLowerCase());
+ expect(responseUpdate.status).toEqual(200);
+ expect(responseUpdate.body.organizationId).toEqual(newOrganization.id);
+
+ // Verify service is now in new organization
+ const serviceAfterUpdate = await getService(newOrganization.id!, testService.name, app);
+ expect(serviceAfterUpdate.organizationId).toEqual(newOrganization.id);
+
+ // Update reference
+ testOrganization = newOrganization;
+ testApiKey = newOrgApiKey;
+
+ await deleteTestOrganization(newOrganization.id!);
+ });
+
+ it('Should return 200 and move contract with single service to new organization', async function () {
+ const newOrganization = await createTestOrganization(ownerUser.username);
+ const newOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(newOrganization.id!, { key: newOrgApiKey, scope: 'ALL' });
+
+ // Create contract with ONLY this service
+ const contractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+ const createResponse = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(contractData);
+
+ expect(createResponse.status).toEqual(201);
+ const contractBefore = createResponse.body as LeanContract;
+ contractsToCleanup.add(contractBefore.userContact.userId);
+ expect(Object.keys(contractBefore.contractedServices).length).toEqual(1);
+ expect(contractBefore.organizationId).toEqual(testOrganization.id);
+
+ // Move service to new organization
+ const updateResponse = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ organizationId: newOrganization.id });
+
+ expect(updateResponse.status).toEqual(200);
+ expect(updateResponse.body.organizationId).toEqual(newOrganization.id);
+
+ // Verify contract was moved to new organization
+ const contractAfter = await getContractByUserId(contractBefore.userContact.userId, app);
+ expect(contractAfter.organizationId).toEqual(newOrganization.id);
+ expect(contractAfter.contractedServices).toHaveProperty(testService.name.toLowerCase());
+
+ // Update references
+ testOrganization = newOrganization;
+ testApiKey = newOrgApiKey;
+
+ await deleteTestOrganization(newOrganization.id!);
+ });
+
+ it('Should return 200 and remove service from multi-service contract', async function () {
+ // Create additional service in same organization
+ const additionalService = await createTestService(testOrganization.id);
+
+ // Create contract with MULTIPLE services
+ const contractData = await generateContract(
+ {
+ [testService.name.toLowerCase()]: testService.activePricings.keys().next().value!,
+ [additionalService.name.toLowerCase()]: additionalService.activePricings.keys().next().value!,
+ },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+ const createResponse = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(contractData);
+
+ expect(createResponse.status).toEqual(201);
+ const contractBefore = createResponse.body as LeanContract;
+ contractsToCleanup.add(contractBefore.userContact.userId);
+ expect(Object.keys(contractBefore.contractedServices).length).toEqual(2);
+ expect(contractBefore.organizationId).toEqual(testOrganization.id);
+ const orgBefore = contractBefore.organizationId;
+
+ // Move service to new organization
+ const newOrganization = await createTestOrganization(ownerUser.username);
+ const updateResponse = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ organizationId: newOrganization.id });
+
+ expect(updateResponse.status).toEqual(200);
+ expect(updateResponse.body.organizationId).toEqual(newOrganization.id);
+
+ // Verify contract STILL in original organization but service was removed
+ const contractAfter = await getContractByUserId(contractBefore.userContact.userId, app);
+ expect(contractAfter.organizationId).toEqual(orgBefore);
+ expect(contractAfter.contractedServices).not.toHaveProperty(testService.name.toLowerCase());
+ expect(contractAfter.contractedServices).toHaveProperty(additionalService.name.toLowerCase());
+ expect(Object.keys(contractAfter.contractedServices).length).toEqual(1);
+
+ // Cleanup
+ await deleteTestService(additionalService.name, testOrganization.id!);
+ await deleteTestOrganization(newOrganization.id!);
+
+ testService.id = undefined; // Mark original service as potentially deleted for afterEach cleanup
+ });
+ it('Should return 200 and ignore invalid fields like activePricings', async function () {
+ const serviceBeforeUpdate = await getService(testOrganization.id!, testService.name, app);
+ expect(serviceBeforeUpdate.activePricings).toBeDefined();
+
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ activePricings: null, description: 'should be ignored' });
+
+ expect(responseUpdate.status).toEqual(200);
+ expect(responseUpdate.body.activePricings).toEqual(serviceBeforeUpdate.activePricings);
+ });
+
+ it('Should return 200 and update both name and organization simultaneously', async function () {
+ const newName = 'service-with-new-org-' + Date.now();
+ const newOrganization = await createTestOrganization(ownerUser.username);
+ const newOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(newOrganization.id!, { key: newOrgApiKey, scope: 'ALL' });
+
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ name: newName, organizationId: newOrganization.id });
+
+ expect(responseUpdate.status).toEqual(200);
+ expect(responseUpdate.body.name).toEqual(newName);
+ expect(responseUpdate.body.organizationId).toEqual(newOrganization.id);
+
+ // Update references
+ testService.name = newName;
+ testOrganization = newOrganization;
+ testApiKey = newOrgApiKey;
+
+ await deleteTestOrganization(newOrganization.id!);
+ });
+
+ it('Should return 200 and update contracts when name and organization change', async function () {
+ const newName = 'service-contract-move-' + Date.now();
+ const newOrganization = await createTestOrganization(ownerUser.username);
+ const newOrgApiKey = generateOrganizationApiKey();
+ await addApiKeyToOrganization(newOrganization.id!, { key: newOrgApiKey, scope: 'ALL' });
+
+ const additionalService = await createTestService(testOrganization.id);
+
+ // Contract with only this service
+ const singleContractData = await generateContract(
+ { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+ const singleContractResponse = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(singleContractData);
+
+ expect(singleContractResponse.status).toEqual(201);
+ const singleContractBefore = singleContractResponse.body as LeanContract;
+ contractsToCleanup.add(singleContractBefore.userContact.userId);
+
+ // Contract with multiple services
+ const multiContractData = await generateContract(
+ {
+ [testService.name.toLowerCase()]: testService.activePricings.keys().next().value!,
+ [additionalService.name.toLowerCase()]:
+ additionalService.activePricings.keys().next().value!,
+ },
+ testOrganization.id!,
+ undefined,
+ app
+ );
+ const multiContractResponse = await request(app)
+ .post(`${baseUrl}/organizations/${testOrganization.id}/contracts`)
+ .set('x-api-key', ownerUser.apiKey)
+ .send(multiContractData);
+
+ expect(multiContractResponse.status).toEqual(201);
+ const multiContractBefore = multiContractResponse.body as LeanContract;
+ contractsToCleanup.add(multiContractBefore.userContact.userId);
+
+ const updateResponse = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ name: newName, organizationId: newOrganization.id });
+
+ expect(updateResponse.status).toEqual(200);
+ expect(updateResponse.body.name).toEqual(newName);
+ expect(updateResponse.body.organizationId).toEqual(newOrganization.id);
+
+ const singleContractAfter = await getContractByUserId(
+ singleContractBefore.userContact.userId,
+ app
+ );
+ expect(singleContractAfter.organizationId).toEqual(newOrganization.id);
+ expect(singleContractAfter.contractedServices).toHaveProperty(newName.toLowerCase());
+ expect(singleContractAfter.contractedServices).not.toHaveProperty(
+ testService.name.toLowerCase()
+ );
+
+ const multiContractAfter = await getContractByUserId(
+ multiContractBefore.userContact.userId,
+ app
+ );
+ expect(multiContractAfter.organizationId).toEqual(testOrganization.id);
+ expect(multiContractAfter.contractedServices).not.toHaveProperty(
+ testService.name.toLowerCase()
+ );
+ expect(multiContractAfter.contractedServices).not.toHaveProperty(newName.toLowerCase());
+ expect(multiContractAfter.contractedServices).toHaveProperty(
+ additionalService.name.toLowerCase()
+ );
+
+ await deleteTestService(additionalService.name, testOrganization.id!);
+ await deleteTestOrganization(newOrganization.id!);
+
+ testService.id = undefined; // Mark original service as potentially deleted for afterEach cleanup
+ });
+
+
+
+ // ======== NEGATIVE TESTS ========
+
+ it('Should return 4XX when trying to rename service to an existing name', async function () {
+ // Create another service
+ const secondService = await createTestService(testOrganization.id);
+
+ // Try to rename first service to second service's name
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ name: secondService.name });
+
+ expect(responseUpdate.status).toBeGreaterThanOrEqual(400);
+ expect(responseUpdate.status).toBeLessThan(500);
+ expect(responseUpdate.body.error).toBeDefined();
+ expect(responseUpdate.body.error.toLowerCase()).toContain('conflict');
+
+ // Cleanup additional service
+ await deleteTestService(secondService.name, testOrganization.id!);
+ });
+
+ it('Should return 4XX when trying to move service to non-existent organization', async function () {
+ const fakeOrgId = '507f1f77bcf86cd799439012';
+
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({ organizationId: fakeOrgId });
+
+ expect(responseUpdate.status).toBeGreaterThanOrEqual(400);
+ expect(responseUpdate.status).toBeLessThan(500);
+ expect(responseUpdate.body.error).toBeDefined();
+ expect(responseUpdate.body.error.toLowerCase()).toContain('not found');
+ });
+
+ it('Should return 404 when trying to update non-existent service', async function () {
+ const nonExistentServiceName = 'non-existent-service-' + Date.now();
+
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${nonExistentServiceName}`)
+ .set('x-api-key', testApiKey)
+ .send({ name: 'new-name' });
+
+ expect(responseUpdate.status).toEqual(404);
+ expect(responseUpdate.body.error).toBeDefined();
+ });
+
+ it('Should ignore empty/null update payload', async function () {
+ const serviceBeforeUpdate = await getService(testOrganization.id!, testService.name, app);
+
+ const responseUpdate = await request(app)
+ .put(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey)
+ .send({});
+
+ expect(responseUpdate.status).toEqual(200);
+ expect(responseUpdate.body.name).toEqual(serviceBeforeUpdate.name);
+ expect(responseUpdate.body.organizationId).toEqual(serviceBeforeUpdate.organizationId);
+ });
+ });
+ describe('DELETE /services/{serviceName}', function () {
+ it('Should return 204', async function () {
const responseDelete = await request(app)
- .delete(`${baseUrl}/services/${serviceName}`)
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/services/${testService.name.toLowerCase()}`)
+ .set('x-api-key', testApiKey);
expect(responseDelete.status).toEqual(204);
- const responseAfter = await request(app)
- .get(`${baseUrl}/services/${serviceName}`)
- .set('x-api-key', adminApiKey);
- expect(responseAfter.status).toEqual(404);
-
- const contractsAfter = await request(app)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send({ filters: { services: [serviceName] } });
- expect(contractsAfter.status).toEqual(200);
- expect(Array.isArray(contractsAfter.body)).toBe(true);
- expect(
- contractsAfter.body.every(
- (c: TestContract) => new Date() > c.billingPeriod.endDate && !c.billingPeriod.autoRenew
- )
- ).toBeTruthy();
+ testService.id = undefined
});
});
describe('GET /services/{serviceName}/pricings', function () {
it('Should return 200: Given existent service name in lower case', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name.toLowerCase()}/pricings`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
@@ -216,17 +620,19 @@ describe('Services API Test Suite', function () {
expect(response.body[0].plans).toBeDefined();
expect(response.body[0].addOns).toBeDefined();
- const service = await getService(testService, app);
- expect(service.name.toLowerCase()).toBe(testService.toLowerCase());
+ const service = await getService(testOrganization.id!, testService.name, app);
+ expect(service.name.toLowerCase()).toBe(testService.name.toLowerCase());
expect(response.body.map((p: ExpectedPricingType) => p.version).sort()).toEqual(
Object.keys(service.activePricings).sort()
);
});
it('Should return 200: Given existent service name in lower case and "archived" in query', async function () {
+ await addArchivedPricingToService(testOrganization.id!, testService.name);
+
const response = await request(app)
- .get(`${baseUrl}/services/${testService}/pricings?pricingStatus=archived`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name.toLowerCase()}/pricings?pricingStatus=archived`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
@@ -236,8 +642,8 @@ describe('Services API Test Suite', function () {
expect(response.body[0].plans).toBeDefined();
expect(response.body[0].addOns).toBeDefined();
- const service = await getService(testService, app);
- expect(service.name.toLowerCase()).toBe(testService.toLowerCase());
+ const service = await getService(testOrganization.id!, testService.name, app);
+ expect(service.name.toLowerCase()).toBe(testService.name.toLowerCase());
expect(response.body.map((p: ExpectedPricingType) => p.version).sort()).toEqual(
Object.keys(service.archivedPricings).sort()
);
@@ -245,8 +651,8 @@ describe('Services API Test Suite', function () {
it('Should return 200: Given existent service name in upper case', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name.toUpperCase()}/pricings`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
@@ -260,117 +666,107 @@ describe('Services API Test Suite', function () {
it('Should return 404 due to service not found', async function () {
const response = await request(app)
.get(`${baseUrl}/services/unexistent-service/pricings`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(404);
- expect(response.body.error).toBe('Service unexistent-service not found');
+ expect(response.body.error).toBe('NOT FOUND: Service with name unexistent-service');
});
});
describe('POST /services/{serviceName}/pricings', function () {
- let versionToAdd = '2025';
- let skipAfterEach = false;
-
- afterEach(async function () {
- if (skipAfterEach) {
- skipAfterEach = false;
- return;
- }
-
- await archivePricingFromService(testService, versionToAdd, app);
- await deletePricingFromService(testService, versionToAdd, app);
- });
-
- it('Should return 200', async function () {
- const serviceBefore = await getService(testService, app);
+ it('Should return 200 when adding a new pricing version to a service', async function () {
+ const serviceBefore = await getService(testOrganization.id!, testService.name, app);
expect(serviceBefore.activePricings).toBeDefined();
const previousActivePricingsAmount = Object.keys(serviceBefore.activePricings).length;
- const newPricingVersion = zoomPricingPath;
- versionToAdd = '2025';
+ const newPricingVersionPath = await getRandomPricingFile(testService.name);
const response = await request(app)
- .post(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', newPricingVersion);
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', testApiKey)
+ .attach('pricing', newPricingVersionPath);
expect(response.status).toEqual(201);
expect(serviceBefore.activePricings).toBeDefined();
const newActivePricingsAmount = Object.keys(response.body.activePricings).length;
expect(newActivePricingsAmount).toBeGreaterThan(previousActivePricingsAmount);
// Check if the new pricing is the latest in activePricings
- const parsedPricing = retrievePricingFromPath(newPricingVersion);
+ const parsedPricing = retrievePricingFromPath(newPricingVersionPath);
expect(
Object.keys(response.body.activePricings).includes(parsedPricing.version)
).toBeTruthy();
});
it('Should return 200 even though the service has no archived pricings', async function () {
- const newService = await createRandomService(app);
- const newPricing = await generatePricingFile(newService.name);
- skipAfterEach = true;
+ const newPricingVersionPath = await getRandomPricingFile(testService.name);
const response = await request(app)
- .post(`${baseUrl}/services/${newService.name}/pricings`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', newPricing);
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', testApiKey)
+ .attach('pricing', newPricingVersionPath);
expect(response.status).toEqual(201);
- expect(newService.activePricings).toBeDefined();
+ expect(response.body.activePricings).toBeDefined();
+
const newActivePricingsAmount = Object.keys(response.body.activePricings).length;
expect(newActivePricingsAmount).toBeGreaterThan(
- Object.keys(newService.activePricings).length
+ Object.keys(testService.activePricings).length
);
});
it('Should return 200 given a pricing with a link', async function () {
- const serviceBefore = await getService(testService, app);
- expect(serviceBefore.activePricings).toBeDefined();
+
+ const previousActivePricingsAmount = Object.keys(testService.activePricings).length;
+ const newPricingVersionPath = await getRandomPricingFile(testService.name);
+ const newPricingVersion = fs.readFileSync(newPricingVersionPath, 'utf-8');
- const previousActivePricingsAmount = Object.keys(serviceBefore.activePricings).length;
-
- versionToAdd = '2019';
+ nock('https://test-domain.com')
+ .get('/test-pricing.yaml')
+ .reply(200, newPricingVersion);
const response = await request(app)
- .post(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey)
+ .post(`${baseUrl}/services/${testService.name}/pricings`)
+ .set('x-api-key', testApiKey)
.send({
pricing:
- 'https://sphere.score.us.es/static/collections/63f74bf8eeed64058364b52e/IEEE TSC 2025/zoom/2019.yml',
+ 'https://test-domain.com/test-pricing.yaml',
});
expect(response.status).toEqual(201);
- expect(serviceBefore.activePricings).toBeDefined();
+ expect(testService.activePricings).toBeDefined();
expect(Object.keys(response.body.activePricings).length).toBeGreaterThan(
previousActivePricingsAmount
);
+
+ // 5. Clean up fetch mock
+ nock.cleanAll();
});
it('Should return 400 given a pricing with a link that do not coincide in saasName', async function () {
- const serviceBefore = await getService(testService, app);
- expect(serviceBefore.activePricings).toBeDefined();
+ const newPricingVersionPath = await getRandomPricingFile("random-name");
+ const newPricingVersion = fs.readFileSync(newPricingVersionPath, 'utf-8');
- skipAfterEach = true;
+ nock('https://test-domain.com')
+ .get('/test-pricing.yaml')
+ .reply(200, newPricingVersion);
const response = await request(app)
.post(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.send({
pricing:
- 'https://sphere.score.us.es/static/collections/63f74bf8eeed64058364b52e/IEEE TSC 2025/zoom/2025.yml',
+ 'https://test-domain.com/test-pricing.yaml',
});
expect(response.status).toEqual(400);
- expect(response.body.error).toBe(
- 'Invalid request: The service name in the pricing file (Zoom - One) does not match the service name in the URL (Zoom)'
- );
+ expect(response.body.error).toBeDefined();
});
});
describe('GET /services/{serviceName}/pricings/{pricingVersion}', function () {
- it('Should return 200: Given existent service name and pricing version', async function () {
+ it('Should return 200: Given existent service name and pricing version', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService}/pricings/2.0.0`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService}/pricings/${testService.activePricings.keys().next().value}`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(response.body.features).toBeDefined();
expect(Object.keys(response.body.features).length).toBeGreaterThan(0);
@@ -384,8 +780,8 @@ describe('Services API Test Suite', function () {
it('Should return 200: Given existent service name in upper case and pricing version', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService}/pricings/2.0.0`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService}/pricings/${testService.activePricings.keys().next().value}`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(200);
expect(response.body.features).toBeDefined();
expect(Object.keys(response.body.features).length).toBeGreaterThan(0);
@@ -399,53 +795,35 @@ describe('Services API Test Suite', function () {
it('Should return 404 due to service not found', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/unexistent-service/pricings/2.0.0`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/unexistent-service/pricings/${testService.activePricings.keys().next().value}`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(404);
- expect(response.body.error).toBe('Service unexistent-service not found');
+ expect(response.body.error).toBe('NOT FOUND: Service with name unexistent-service');
});
it('Should return 404 due to pricing not found', async function () {
const response = await request(app)
- .get(`${baseUrl}/services/${testService}/pricings/unexistent-version`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name}/pricings/unexistent-version`)
+ .set('x-api-key', testApiKey);
expect(response.status).toEqual(404);
expect(response.body.error).toBe(
- `Pricing version unexistent-version not found for service ${testService}`
+ `Pricing version unexistent-version not found for service ${testService.name}`
);
});
});
describe('PUT /services/{serviceName}/pricings/{pricingVersion}', function () {
- const versionToArchive = '2.0.0';
-
- afterEach(async function () {
- await request(app)
- .put(`${baseUrl}/services/${testService}/pricings/${versionToArchive}?availability=active`)
- .set('x-api-key', adminApiKey);
- });
-
it('Should return 200: Changing visibility using default value', async function () {
- const responseBefore = await request(app)
- .get(`${baseUrl}/services/${testService}`)
- .set('x-api-key', adminApiKey);
- expect(responseBefore.status).toEqual(200);
- expect(responseBefore.body.activePricings).toBeDefined();
- expect(
- Object.keys(responseBefore.body.activePricings).includes(versionToArchive)
- ).toBeTruthy();
- expect(
- Object.keys(responseBefore.body.archivedPricings).includes(versionToArchive)
- ).toBeFalsy();
-
+
+ const pricingToArchiveContent = await addPricingToService(testOrganization.id!, testService.name, undefined, true);
+ const versionToArchive = getVersionFromPricing(pricingToArchiveContent);
+ const fallbackPlan = getFirstPlanFromPricing(pricingToArchiveContent);
+
const responseUpdate = await request(app)
.put(`${baseUrl}/services/${testService}/pricings/${versionToArchive}`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.send({
- subscriptionPlan: 'PRO',
- subscriptionAddOns: {
- largeMeetings: 1,
- },
+ subscriptionPlan: fallbackPlan,
});
expect(responseUpdate.status).toEqual(200);
expect(responseUpdate.body.activePricings).toBeDefined();
@@ -458,28 +836,17 @@ describe('Services API Test Suite', function () {
});
it('Should return 200: Changing visibility using "archived"', async function () {
- const responseBefore = await request(app)
- .get(`${baseUrl}/services/${testService}`)
- .set('x-api-key', adminApiKey);
- expect(responseBefore.status).toEqual(200);
- expect(responseBefore.body.activePricings).toBeDefined();
- expect(
- Object.keys(responseBefore.body.activePricings).includes(versionToArchive)
- ).toBeTruthy();
- expect(
- Object.keys(responseBefore.body.archivedPricings).includes(versionToArchive)
- ).toBeFalsy();
-
+ const pricingToArchiveContent = await addPricingToService(testOrganization.id!, testService.name, undefined, true);
+ const versionToArchive = getVersionFromPricing(pricingToArchiveContent);
+ const fallbackPlan = getFirstPlanFromPricing(pricingToArchiveContent);
+
const responseUpdate = await request(app)
.put(
`${baseUrl}/services/${testService}/pricings/${versionToArchive}?availability=archived`
)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.send({
- subscriptionPlan: 'PRO',
- subscriptionAddOns: {
- largeMeetings: 1,
- },
+ subscriptionPlan: fallbackPlan,
});
expect(responseUpdate.status).toEqual(200);
expect(responseUpdate.body.activePricings).toBeDefined();
@@ -492,75 +859,69 @@ describe('Services API Test Suite', function () {
});
it('Should return 200: Changing visibility using "active"', async function () {
- const responseBefore = await request(app)
- .put(`${baseUrl}/services/${testService}/pricings/${versionToArchive}`)
- .set('x-api-key', adminApiKey)
- .send({
- subscriptionPlan: 'PRO',
- subscriptionAddOns: {
- largeMeetings: 1,
- },
- });
- expect(responseBefore.status).toEqual(200);
- expect(responseBefore.body.activePricings).toBeDefined();
- expect(
- Object.keys(responseBefore.body.activePricings).includes(versionToArchive)
- ).toBeFalsy();
- expect(
- Object.keys(responseBefore.body.archivedPricings).includes(versionToArchive)
- ).toBeTruthy();
+ const archivedVersion = await addArchivedPricingToService(testOrganization.id!, testService.name);
const responseUpdate = await request(app)
- .put(`${baseUrl}/services/${testService}/pricings/${versionToArchive}?availability=active`)
- .set('x-api-key', adminApiKey);
+ .put(`${baseUrl}/services/${testService}/pricings/${archivedVersion}?availability=active`)
+ .set('x-api-key', testApiKey);
expect(responseUpdate.status).toEqual(200);
expect(responseUpdate.body.activePricings).toBeDefined();
expect(
- Object.keys(responseUpdate.body.activePricings).includes(versionToArchive)
+ Object.keys(responseUpdate.body.activePricings).includes(archivedVersion)
).toBeTruthy();
expect(
- Object.keys(responseUpdate.body.archivedPricings).includes(versionToArchive)
+ Object.keys(responseUpdate.body.archivedPricings).includes(archivedVersion)
).toBeFalsy();
});
- it(
- 'Should return 200 and novate all contracts: Changing visibility using "archived"',
+ it('Should return 200 and novate all contracts: Changing visibility using "archived"',
async function () {
- await createRandomContractsForService(testService, versionToArchive, 5, app);
+ const newPricingContent = await addPricingToService(testOrganization.id!, testService.name, undefined, true);
+ const newVersion = getVersionFromPricing(newPricingContent);
+ const fallbackPlan = getFirstPlanFromPricing(newPricingContent);
+ const versionToArchive = testService.activePricings.keys().next().value;
+
+ const testContract = await createTestContract(
+ testOrganization.id!,
+ [testService],
+ app
+ );
const responseUpdate = await request(app)
.put(
`${baseUrl}/services/${testService}/pricings/${versionToArchive}?availability=archived`
)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.send({
- subscriptionPlan: 'PRO',
- subscriptionAddOns: {
- largeMeetings: 1,
- },
+ subscriptionPlan: fallbackPlan,
});
expect(responseUpdate.status).toEqual(200);
expect(responseUpdate.body.activePricings).toBeDefined();
expect(
- Object.keys(responseUpdate.body.activePricings).includes(versionToArchive)
- ).toBeFalsy();
+ Object.keys(responseUpdate.body.activePricings).includes(newVersion)
+ ).toBeTruthy();
expect(
- Object.keys(responseUpdate.body.archivedPricings).includes(versionToArchive)
+ Object.keys(responseUpdate.body.archivedPricings).includes(versionToArchive!)
).toBeTruthy();
const reponseContractsAfter = await request(app)
.get(`${baseUrl}/contracts`)
- .set('x-api-key', adminApiKey)
- .send({ filters: { services: [testService] } });
+ .set('x-api-key', testApiKey)
+ .send({ filters: { services: [testService.name] } });
expect(reponseContractsAfter.status).toEqual(200);
expect(Array.isArray(reponseContractsAfter.body)).toBe(true);
for (const contract of reponseContractsAfter.body) {
- expect(contract.contractedServices[testService.toLowerCase()]).toBeDefined();
- expect(contract.contractedServices[testService.toLowerCase()]).not.toEqual(
- versionToArchive
+ expect(contract.contractedServices[testService.name.toLowerCase()]).toBeDefined();
+ expect(contract.contractedServices[testService.name.toLowerCase()]).not.toEqual(
+ testContract.contractedServices[testService.name.toLowerCase()]
+ );
+ expect(contract.subscriptionPlans[testService.name.toLowerCase()]).toEqual(
+ fallbackPlan
);
+
+ expect(Object.keys(contract.subscriptionAddOns[testService.name.toLowerCase()]).length).toBe(0);
// Alternative approach with try/catch
try {
@@ -568,7 +929,7 @@ describe('Services API Test Suite', function () {
contractedServices: contract.contractedServices,
subscriptionPlans: contract.subscriptionPlans,
subscriptionAddOns: contract.subscriptionAddOns,
- });
+ }, testOrganization.id!);
} catch (error) {
expect.fail(`Contract subscription validation failed: ${(error as Error).message}`);
}
@@ -578,11 +939,18 @@ describe('Services API Test Suite', function () {
);
it('Should return 400: Changing visibility using "invalidValue"', async function () {
+ const pricingToArchiveContent = await addPricingToService(testOrganization.id!, testService.name, undefined,true);
+ const versionToArchive = getVersionFromPricing(pricingToArchiveContent);
+ const fallbackPlan = getFirstPlanFromPricing(pricingToArchiveContent);
+
const responseUpdate = await request(app)
.put(
`${baseUrl}/services/${testService}/pricings/${versionToArchive}?availability=invalidValue`
)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testApiKey)
+ .send({
+ subscriptionPlan: fallbackPlan,
+ });
expect(responseUpdate.status).toEqual(400);
expect(responseUpdate.body.error).toBe(
'Invalid availability status. Either provide "active" or "archived"'
@@ -590,119 +958,70 @@ describe('Services API Test Suite', function () {
});
it('Should return 400: Changing visibility to archived when is the last activePricing', async function () {
- await request(app)
- .put(`${baseUrl}/services/${testService}/pricings/${versionToArchive}`)
- .set('x-api-key', adminApiKey)
- .send({
- subscriptionPlan: 'PRO',
- subscriptionAddOns: {
- largeMeetings: 1,
- },
- })
- .expect(200);
-
- const lastVersionToArchive = '2023';
+ const versionToArchive = testService.activePricings.keys().next().value;
const responseUpdate = await request(app)
- .put(`${baseUrl}/services/${testService}/pricings/${lastVersionToArchive}`)
- .set('x-api-key', adminApiKey);
+ .put(`${baseUrl}/services/${testService.name}/pricings/${versionToArchive}`)
+ .set('x-api-key', testApiKey);
+
expect(responseUpdate.status).toEqual(400);
expect(responseUpdate.body.error).toBe(
- `You cannot archive the last active pricing for service ${testService}`
+ `You cannot archive the last active pricing for service ${testService.name}`
);
});
});
describe('DELETE /services/{serviceName}/pricings/{pricingVersion}', function () {
it('Should return 204', async function () {
- const versionToDelete = '2025';
-
- const responseBefore = await request(app)
- .post(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', zoomPricingPath);
- expect(responseBefore.status).toEqual(201);
- expect(responseBefore.body.activePricings).toBeDefined();
- expect(
- Object.keys(responseBefore.body.activePricings).includes(versionToDelete)
- ).toBeTruthy();
-
- // Necesary to delete
- await archivePricingFromService(testService, versionToDelete, app);
+ const versionToDelete = await addArchivedPricingToService(testOrganization.id!, testService.name);
const responseDelete = await request(app)
- .delete(`${baseUrl}/services/${testService}/pricings/${versionToDelete}`)
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${versionToDelete}`)
+ .set('x-api-key', testApiKey);
expect(responseDelete.status).toEqual(204);
const responseAfter = await request(app)
- .get(`${baseUrl}/services/${testService}`)
- .set('x-api-key', adminApiKey);
- expect(responseAfter.status).toEqual(200);
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testApiKey);
+
+ expect(responseAfter.status).toEqual(200);
expect(responseAfter.body.activePricings).toBeDefined();
expect(Object.keys(responseAfter.body.activePricings).includes(versionToDelete)).toBeFalsy();
});
it('Should return 204 with semver pricing version', async function () {
- const versionToDelete = '2.0.0';
-
- const responseBefore = await request(app)
- .post(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', zoomPricingPath);
- expect(responseBefore.status).toEqual(201);
- expect(responseBefore.body.activePricings).toBeDefined();
- expect(
- Object.keys(responseBefore.body.activePricings).includes(versionToDelete)
- ).toBeTruthy();
-
- // Necesary to delete
- await archivePricingFromService(testService, versionToDelete, app);
+ const versionToDelete = await addArchivedPricingToService(testOrganization.id!, testService.name, "2.0.0");
const responseDelete = await request(app)
- .delete(`${baseUrl}/services/${testService}/pricings/${versionToDelete}`)
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${versionToDelete}`)
+ .set('x-api-key', testApiKey);
expect(responseDelete.status).toEqual(204);
const responseAfter = await request(app)
- .get(`${baseUrl}/services/${testService}`)
- .set('x-api-key', adminApiKey);
+ .get(`${baseUrl}/services/${testService.name}`)
+ .set('x-api-key', testApiKey);
expect(responseAfter.status).toEqual(200);
expect(responseAfter.body.activePricings).toBeDefined();
expect(Object.keys(responseAfter.body.activePricings).includes(versionToDelete)).toBeFalsy();
});
it('Should return 404 since pricing to delete has not been archived before deleting', async function () {
- const versionToDelete = '2025';
-
- const responseBefore = await request(app)
- .post(`${baseUrl}/services/${testService}/pricings`)
- .set('x-api-key', adminApiKey)
- .attach('pricing', zoomPricingPath);
- if (responseBefore.status === 400) {
- expect(responseBefore.body.error).toContain('exists');
- } else {
- expect(responseBefore.status).toEqual(201);
- expect(responseBefore.body.activePricings).toBeDefined();
- expect(
- Object.keys(responseBefore.body.activePricings).includes(versionToDelete)
- ).toBeTruthy();
- }
+ const versionToDelete = await addPricingToService(testOrganization.id!, testService.name);
const responseDelete = await request(app)
- .delete(`${baseUrl}/services/${testService}/pricings/${versionToDelete}`)
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${versionToDelete}`)
+ .set('x-api-key', testApiKey);
- expect(responseDelete.status).toEqual(403);
+ expect(responseDelete.status).toEqual(404);
expect(responseDelete.body.error).toBe(
- `Forbidden: You cannot delete an active pricing version ${versionToDelete} for service ${testService}. Please archive it first.`
+ `Invalid request: No archived version ${versionToDelete} found for service ${testService.name}. Remember that a pricing must be archived before it can be deleted.`
);
// Necesary to delete
- await archivePricingFromService(testService, versionToDelete, app);
+ await archivePricingFromService(testOrganization.id!, testService.name, versionToDelete, app);
await request(app)
.delete(`${baseUrl}/services/${testService}/pricings/${versionToDelete}`)
- .set('x-api-key', adminApiKey)
+ .set('x-api-key', testApiKey)
.expect(204);
});
@@ -710,11 +1029,12 @@ describe('Services API Test Suite', function () {
const versionToDelete = 'invalid';
const responseDelete = await request(app)
- .delete(`${baseUrl}/services/${testService}/pricings/${versionToDelete}`)
- .set('x-api-key', adminApiKey);
+ .delete(`${baseUrl}/services/${testService.name}/pricings/${versionToDelete}`)
+ .set('x-api-key', testApiKey);
expect(responseDelete.status).toEqual(404);
+
expect(responseDelete.body.error).toBe(
- `Invalid request: No archived version ${versionToDelete} found for service ${testService}. Remember that a pricing must be archived before it can be deleted.`
+ `Invalid request: No archived version ${versionToDelete} found for service ${testService.name}. Remember that a pricing must be archived before it can be deleted.`
);
});
});
@@ -724,7 +1044,7 @@ describe('Services API Test Suite', function () {
// Checks if there are services to delete
const responseIndexBeforeDelete = await request(app)
.get(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testApiKey);
expect(responseIndexBeforeDelete.status).toEqual(200);
expect(Array.isArray(responseIndexBeforeDelete.body)).toBe(true);
@@ -733,17 +1053,20 @@ describe('Services API Test Suite', function () {
// Deletes all services
const responseDelete = await request(app)
.delete(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testApiKey);
+
expect(responseDelete.status).toEqual(200);
// Checks if there are no services after delete
const responseIndexAfterDelete = await request(app)
.get(`${baseUrl}/services`)
- .set('x-api-key', adminApiKey);
+ .set('x-api-key', testApiKey);
expect(responseIndexAfterDelete.status).toEqual(200);
expect(Array.isArray(responseIndexAfterDelete.body)).toBe(true);
expect(responseIndexAfterDelete.body.length).toBe(0);
+
+ testService.id = undefined;
});
});
});
diff --git a/api/src/test/types/models/Contract.ts b/api/src/test/types/models/Contract.ts
index 0a1f490..c0a130a 100644
--- a/api/src/test/types/models/Contract.ts
+++ b/api/src/test/types/models/Contract.ts
@@ -15,6 +15,7 @@ export interface TestContract {
autoRenew: boolean;
renewalDays: number;
};
+ organizationId: string;
usageLevels: Record>;
contractedServices: Record;
subscriptionPlans: Record;
diff --git a/api/src/test/types/models/Service.ts b/api/src/test/types/models/Service.ts
index 8f1e26a..be926aa 100644
--- a/api/src/test/types/models/Service.ts
+++ b/api/src/test/types/models/Service.ts
@@ -5,6 +5,7 @@ export interface PricingEntry {
export interface TestService {
name: string;
+ organizationId: string;
activePricings: Record;
archivedPricings: Record;
}
\ No newline at end of file
diff --git a/api/src/test/unit-tests/routeMatcher.test.ts b/api/src/test/unit-tests/routeMatcher.test.ts
new file mode 100644
index 0000000..b32d41b
--- /dev/null
+++ b/api/src/test/unit-tests/routeMatcher.test.ts
@@ -0,0 +1,97 @@
+/**
+ * Unit tests for the route matcher utility
+ *
+ * Run with: npm test or pnpm test
+ */
+
+import { describe, it, expect } from 'vitest';
+import { matchPath, extractApiPath, findMatchingPattern } from '../../main/utils/routeMatcher';
+
+describe('routeMatcher', () => {
+ describe('matchPath', () => {
+ it('should match exact paths', () => {
+ expect(matchPath('/users', '/users')).toBe(true);
+ expect(matchPath('/users/profile', '/users/profile')).toBe(true);
+ expect(matchPath('/users', '/services')).toBe(false);
+ });
+
+ it('should handle trailing slashes', () => {
+ expect(matchPath('/users/', '/users')).toBe(true);
+ expect(matchPath('/users', '/users/')).toBe(true);
+ expect(matchPath('/users/', '/users/')).toBe(true);
+ });
+
+ it('should handle paths without leading slash', () => {
+ expect(matchPath('users', '/users')).toBe(true);
+ expect(matchPath('/users', 'users')).toBe(true);
+ expect(matchPath('users', 'users')).toBe(true);
+ });
+
+ it('should match single segment wildcard (*)', () => {
+ expect(matchPath('/users/*', '/users/john')).toBe(true);
+ expect(matchPath('/users/*', '/users/jane')).toBe(true);
+ expect(matchPath('/users/*', '/users/john/profile')).toBe(false);
+ expect(matchPath('/users/*/profile', '/users/john/profile')).toBe(true);
+ expect(matchPath('/users/*/profile', '/users/john/settings')).toBe(false);
+ });
+
+ it('should match multi-segment wildcard (**)', () => {
+ expect(matchPath('/users/**', '/users')).toBe(true);
+ expect(matchPath('/users/**', '/users/john')).toBe(true);
+ expect(matchPath('/users/**', '/users/john/profile')).toBe(true);
+ expect(matchPath('/users/**', '/users/john/profile/settings')).toBe(true);
+ expect(matchPath('/users/**', '/organizations/org1')).toBe(false);
+ });
+
+ it('should match organizations/** pattern', () => {
+ expect(matchPath('/organizations/**', '/organizations')).toBe(true);
+ expect(matchPath('/organizations/**', '/organizations/org1')).toBe(true);
+ expect(matchPath('/organizations/**', '/organizations/org1/members')).toBe(true);
+ expect(matchPath('/organizations/**', '/users/john')).toBe(false);
+ });
+
+ it('should match complex patterns', () => {
+ expect(matchPath('/api/*/services', '/api/v1/services')).toBe(true);
+ expect(matchPath('/api/*/services', '/api/v2/services')).toBe(true);
+ expect(matchPath('/api/*/services', '/api/v1/users')).toBe(false);
+ });
+ });
+
+ describe('extractApiPath', () => {
+ it('should extract path without base URL', () => {
+ expect(extractApiPath('/api/v1/users', '/api/v1')).toBe('/users');
+ expect(extractApiPath('/api/v1/services/123', '/api/v1')).toBe('/services/123');
+ expect(extractApiPath('/api/v1/', '/api/v1')).toBe('/');
+ });
+
+ it('should return path as-is when no base URL provided', () => {
+ expect(extractApiPath('/users')).toBe('/users');
+ expect(extractApiPath('/services/123')).toBe('/services/123');
+ });
+
+ it('should handle paths without leading slash', () => {
+ expect(extractApiPath('api/v1/users', '/api/v1')).toBe('/users');
+ expect(extractApiPath('/api/v1/users', 'api/v1')).toBe('/users');
+ });
+ });
+
+ describe('findMatchingPattern', () => {
+ const patterns = [
+ '/users/**',
+ '/services/*',
+ '/organizations/*/members',
+ '/analytics/**',
+ ];
+
+ it('should find the first matching pattern', () => {
+ expect(findMatchingPattern(patterns, '/users/john')).toBe('/users/**');
+ expect(findMatchingPattern(patterns, '/services/svc1')).toBe('/services/*');
+ expect(findMatchingPattern(patterns, '/organizations/org1/members')).toBe('/organizations/*/members');
+ });
+
+ it('should return null when no pattern matches', () => {
+ expect(findMatchingPattern(patterns, '/contracts/123')).toBe(null);
+ expect(findMatchingPattern(patterns, '/features')).toBe(null);
+ });
+ });
+});
diff --git a/api/src/test/unit-tests/version-formatter.test.ts b/api/src/test/unit-tests/version-formatter.test.ts
new file mode 100644
index 0000000..a0b6fd7
--- /dev/null
+++ b/api/src/test/unit-tests/version-formatter.test.ts
@@ -0,0 +1,350 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { resetEscapeVersionInService, resetEscapePricingVersion } from '../../main/utils/services/helpers';
+import { LeanService, PricingEntry } from '../../main/types/models/Service';
+import { LeanPricing } from '../../main/types/models/Pricing';
+
+describe('resetEscapeVersionInService', () => {
+ describe('activePricings - basic cases', () => {
+ it('should replace underscores with dots in activePricings keys', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1_0_0', { id: 'price1', url: 'http://example.com/1.0.0' }],
+ ['2_5_3', { id: 'price2', url: 'http://example.com/2.5.3' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('1.0.0')).toBe(true);
+ expect(service.activePricings.has('2.5.3')).toBe(true);
+ expect(service.activePricings.has('1_0_0')).toBe(false);
+ expect(service.activePricings.has('2_5_3')).toBe(false);
+ expect(service.activePricings.get('1.0.0')?.id).toBe('price1');
+ expect(service.activePricings.get('2.5.3')?.id).toBe('price2');
+ });
+
+ it('should not modify keys that do not contain underscores', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1.0.0', { id: 'price1', url: 'http://example.com/1.0.0' }],
+ ['2.5.3', { id: 'price2', url: 'http://example.com/2.5.3' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('1.0.0')).toBe(true);
+ expect(service.activePricings.has('2.5.3')).toBe(true);
+ expect(service.activePricings.size).toBe(2);
+ });
+
+ it('should handle empty activePricings Map', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map()
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.size).toBe(0);
+ });
+
+ it('should handle mixed keys (some with underscores, some without)', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1_0_0', { id: 'price1', url: 'http://example.com/1.0.0' }],
+ ['2.5.3', { id: 'price2', url: 'http://example.com/2.5.3' }],
+ ['3_1_2', { id: 'price3', url: 'http://example.com/3.1.2' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('1.0.0')).toBe(true);
+ expect(service.activePricings.has('2.5.3')).toBe(true);
+ expect(service.activePricings.has('3.1.2')).toBe(true);
+ expect(service.activePricings.has('1_0_0')).toBe(false);
+ expect(service.activePricings.has('3_1_2')).toBe(false);
+ expect(service.activePricings.size).toBe(3);
+ });
+ });
+
+ describe('activePricings - edge cases', () => {
+ it('should handle versions with multiple underscores', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1_0_0_beta_1', { id: 'price1', url: 'http://example.com' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('1.0.0.beta.1')).toBe(true);
+ expect(service.activePricings.has('1_0_0_beta_1')).toBe(false);
+ });
+
+ it('should handle versions with only underscores', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['___', { id: 'price1', url: 'http://example.com' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('...')).toBe(true);
+ expect(service.activePricings.has('___')).toBe(false);
+ });
+
+ it('should handle version keys with special characters', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['v1_0_0-alpha', { id: 'price1', url: 'http://example.com' }],
+ ['2_5_3+build123', { id: 'price2', url: 'http://example.com' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('v1.0.0-alpha')).toBe(true);
+ expect(service.activePricings.has('2.5.3+build123')).toBe(true);
+ });
+ });
+
+ describe('archivedPricings - basic cases', () => {
+ it('should replace underscores with dots in archivedPricings keys', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map(),
+ archivedPricings: new Map([
+ ['0_9_0', { id: 'price1', url: 'http://example.com/0.9.0' }],
+ ['1_0_0', { id: 'price2', url: 'http://example.com/1.0.0' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.archivedPricings!.has('0.9.0')).toBe(true);
+ expect(service.archivedPricings!.has('1.0.0')).toBe(true);
+ expect(service.archivedPricings!.has('0_9_0')).toBe(false);
+ expect(service.archivedPricings!.has('1_0_0')).toBe(false);
+ });
+
+ it('should handle undefined archivedPricings', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1_0_0', { id: 'price1', url: 'http://example.com' }]
+ ])
+ };
+
+ expect(() => resetEscapeVersionInService(service)).not.toThrow();
+ });
+
+ it('should handle empty archivedPricings Map', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map(),
+ archivedPricings: new Map()
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.archivedPricings!.size).toBe(0);
+ });
+ });
+
+ describe('activePricings and archivedPricings together', () => {
+ it('should process both activePricings and archivedPricings correctly', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['2_0_0', { id: 'active1', url: 'http://example.com/2.0.0' }],
+ ['3_0_0', { id: 'active2', url: 'http://example.com/3.0.0' }]
+ ]),
+ archivedPricings: new Map([
+ ['1_0_0', { id: 'archived1', url: 'http://example.com/1.0.0' }],
+ ['1_5_0', { id: 'archived2', url: 'http://example.com/1.5.0' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ expect(service.activePricings.has('2.0.0')).toBe(true);
+ expect(service.activePricings.has('3.0.0')).toBe(true);
+ expect(service.archivedPricings!.has('1.0.0')).toBe(true);
+ expect(service.archivedPricings!.has('1.5.0')).toBe(true);
+
+ expect(service.activePricings.has('2_0_0')).toBe(false);
+ expect(service.archivedPricings!.has('1_0_0')).toBe(false);
+
+ expect(service.activePricings.size).toBe(2);
+ expect(service.archivedPricings!.size).toBe(2);
+ });
+ });
+
+ describe('potential bugs - collision scenarios', () => {
+ it('BUG: should handle collision when both "1_0_0" and "1.0.0" exist', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1_0_0', { id: 'underscore', url: 'http://example.com/underscore' }],
+ ['1.0.0', { id: 'dot', url: 'http://example.com/dot' }]
+ ])
+ };
+
+ resetEscapeVersionInService(service);
+
+ // This is a potential bug: which entry should remain?
+ // The current implementation will overwrite one with the other
+ expect(service.activePricings.has('1.0.0')).toBe(true);
+
+ // After transformation, we should only have one entry for "1.0.0"
+ // But which one? The function processes in iteration order
+ const entry = service.activePricings.get('1.0.0');
+
+ // The last processed entry will win
+ // Since Maps iterate in insertion order, "1.0.0" (dot) is second
+ // First "1_0_0" gets converted to "1.0.0" (overwrites nothing, creates new)
+ // Then "1.0.0" stays as "1.0.0" (no change needed, already exists)
+ // But the delete happens AFTER set, so order matters
+ console.log('Entry after collision:', entry);
+ });
+
+ it('BUG: modifying Map while iterating - order dependency', () => {
+ const service: LeanService = {
+ name: 'Test Service',
+ disabled: false,
+ organizationId: 'org123',
+ activePricings: new Map([
+ ['1_0_0', { id: 'first', url: 'url1' }],
+ ['2_0_0', { id: 'second', url: 'url2' }],
+ ['3_0_0', { id: 'third', url: 'url3' }]
+ ])
+ };
+
+ const originalSize = service.activePricings.size;
+ resetEscapeVersionInService(service);
+
+ // Should maintain the same number of entries
+ expect(service.activePricings.size).toBe(originalSize);
+
+ // All entries should be transformed
+ expect(service.activePricings.has('1.0.0')).toBe(true);
+ expect(service.activePricings.has('2.0.0')).toBe(true);
+ expect(service.activePricings.has('3.0.0')).toBe(true);
+ });
+ });
+});
+
+describe('resetEscapePricingVersion', () => {
+ it('should replace underscores with dots in pricing version', () => {
+ const pricing: LeanPricing = {
+ version: '1_0_0',
+ currency: 'USD',
+ createdAt: new Date(),
+ features: {}
+ };
+
+ resetEscapePricingVersion(pricing);
+
+ expect(pricing.version).toBe('1.0.0');
+ });
+
+ it('should handle version without underscores', () => {
+ const pricing: LeanPricing = {
+ version: '1.0.0',
+ currency: 'USD',
+ createdAt: new Date(),
+ features: {}
+ };
+
+ resetEscapePricingVersion(pricing);
+
+ expect(pricing.version).toBe('1.0.0');
+ });
+
+ it('should handle complex version strings', () => {
+ const pricing: LeanPricing = {
+ version: '2_5_3_beta_1',
+ currency: 'EUR',
+ createdAt: new Date(),
+ features: {}
+ };
+
+ resetEscapePricingVersion(pricing);
+
+ expect(pricing.version).toBe('2.5.3.beta.1');
+ });
+
+ it('should mutate the original pricing object', () => {
+ const pricing: LeanPricing = {
+ version: '3_2_1',
+ currency: 'USD',
+ createdAt: new Date(),
+ features: {}
+ };
+
+ const originalRef = pricing;
+ resetEscapePricingVersion(pricing);
+
+ expect(originalRef.version).toBe('3.2.1');
+ expect(pricing).toBe(originalRef);
+ });
+
+ it('should handle empty version string', () => {
+ const pricing: LeanPricing = {
+ version: '',
+ currency: 'USD',
+ createdAt: new Date(),
+ features: {}
+ };
+
+ resetEscapePricingVersion(pricing);
+
+ expect(pricing.version).toBe('');
+ });
+
+ it('should handle version with only underscores', () => {
+ const pricing: LeanPricing = {
+ version: '___',
+ currency: 'USD',
+ createdAt: new Date(),
+ features: {}
+ };
+
+ resetEscapePricingVersion(pricing);
+
+ expect(pricing.version).toBe('...');
+ });
+});
diff --git a/api/src/test/user.test.ts b/api/src/test/user.test.ts
index 80f85d8..35d62f9 100644
--- a/api/src/test/user.test.ts
+++ b/api/src/test/user.test.ts
@@ -1,97 +1,474 @@
+import { Server } from 'http';
import request from 'supertest';
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import { USER_ROLES } from '../main/types/permissions';
import { baseUrl, getApp, shutdownApp } from './utils/testApp';
-import { Server } from 'http';
-import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
-import {
- createTestUser,
- deleteTestUser,
-} from './utils/users/userTestUtils';
-import { USER_ROLES } from '../main/types/models/User';
-import { createRandomContract } from './utils/contracts/contracts';
-
-describe('User API Test Suite', function () {
+import { createTestUser, deleteTestUser } from './utils/users/userTestUtils';
+import OrganizationService from '../main/services/OrganizationService';
+import container from '../main/config/container';
+import { addMemberToOrganization, createTestOrganization, deleteTestOrganization } from './utils/organization/organizationTestUtils';
+
+describe('User API routes', function () {
let app: Server;
let adminUser: any;
let adminApiKey: string;
+ let organizationService: OrganizationService;
+ const usersToCleanup: Set = new Set();
+ const orgToCleanup: Set = new Set();
+
+ const trackUserForCleanup = (user?: any) => {
+ if (user?.username && user.username !== adminUser?.username) {
+ usersToCleanup.add(user.username);
+ }
+ };
+ const trackOrganizationForCleanup = (organization?: any) => {
+ if (organization?.id) {
+ orgToCleanup.add(organization.id);
+ }
+ };
beforeAll(async function () {
app = await getApp();
- // Create an admin user for tests
adminUser = await createTestUser('ADMIN');
adminApiKey = adminUser.apiKey;
+ organizationService = container.resolve('organizationService');
+ });
+
+ afterEach(async function () {
+ for (const username of usersToCleanup) {
+ await deleteTestUser(username);
+ }
+
+ for (const id of orgToCleanup) {
+ await deleteTestOrganization(id);
+ }
+ usersToCleanup.clear();
+ orgToCleanup.clear();
});
afterAll(async function () {
- // Clean up the created admin user
if (adminUser?.username) {
await deleteTestUser(adminUser.username);
}
await shutdownApp();
});
- describe('Authentication and API Keys', function () {
- it('Should authenticate a user and return their information', async function () {
- const response = await request(app).post(`${baseUrl}/users/authenticate`).send({
- username: adminUser.username,
- password: 'password123',
- });
+ describe('POST /users/authenticate', function () {
+ it('returns 200 when credentials are valid', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/users/authenticate`)
+ .send({ username: adminUser.username, password: 'password123' });
expect(response.status).toBe(200);
expect(response.body.apiKey).toBeDefined();
expect(response.body.apiKey).toBe(adminApiKey);
+ expect(response.body.username).toBe(adminUser.username);
+ expect(response.body.role).toBe('ADMIN');
});
- it('Should regenerate an API Key for a user', async function () {
- const oldApiKey = adminUser.apiKey;
+ it('returns 401 when password is invalid', async function () {
const response = await request(app)
- .put(`${baseUrl}/users/${adminUser.username}/api-key`)
- .set('x-api-key', oldApiKey);
+ .post(`${baseUrl}/users/authenticate`)
+ .send({ username: adminUser.username, password: 'wrong-password' });
- expect(response.status).toBe(200);
- expect(response.body.apiKey).toBeDefined();
- expect(response.body.apiKey).not.toBe(oldApiKey);
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBeDefined();
+ });
- // Update the API Key for future tests
- adminApiKey = response.body.apiKey;
- // Update the user in the database
- const updatedUser = (await request(app).get(`${baseUrl}/users/${adminUser.username}`).set('x-api-key', adminApiKey)).body;
- adminUser = updatedUser;
+ it('returns 422 when required fields are missing', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/users/authenticate`)
+ .send({ username: adminUser.username });
+
+ expect(response.status).toBe(422);
+ expect(response.body.error).toBeDefined();
});
});
- describe('User Management', function () {
- let testUser: any;
+ describe('GET /users', function () {
+ it('returns 401 when api key is missing', async function () {
+ const response = await request(app).get(`${baseUrl}/users`);
- afterEach(async function () {
- if (testUser?.username) {
- await deleteTestUser(testUser.username);
- testUser = null;
- }
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
});
- it('Should create a new user', async function () {
+ // ============================================
+ // List All Mode (without q parameter)
+ // ============================================
+ describe('List all mode (without q parameter)', function () {
+ it('returns 200 with paginated data structure', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('data');
+ expect(response.body).toHaveProperty('pagination');
+ expect(Array.isArray(response.body.data)).toBeTruthy();
+ expect(response.body.data.length).toBeGreaterThan(0);
+ });
+
+ it('returns correct pagination metadata', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?offset=0&limit=5`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.pagination).toEqual({
+ offset: 0,
+ limit: 5,
+ total: expect.any(Number),
+ page: 1,
+ pages: expect.any(Number),
+ });
+ });
+
+ it('respects offset and limit parameters', async function () {
+ const testUser1 = await createTestUser('USER', 'listuser1');
+ const testUser2 = await createTestUser('USER', 'listuser2');
+ const testUser3 = await createTestUser('USER', 'listuser3');
+
+ trackUserForCleanup(testUser1);
+ trackUserForCleanup(testUser2);
+ trackUserForCleanup(testUser3);
+
+ const page1 = await request(app)
+ .get(`${baseUrl}/users?offset=0&limit=2`)
+ .set('x-api-key', adminApiKey);
+
+ expect(page1.status).toBe(200);
+ expect(page1.body.data.length).toBeLessThanOrEqual(2);
+ expect(page1.body.pagination.offset).toBe(0);
+ expect(page1.body.pagination.limit).toBe(2);
+
+ const page2 = await request(app)
+ .get(`${baseUrl}/users?offset=2&limit=2`)
+ .set('x-api-key', adminApiKey);
+
+ expect(page2.status).toBe(200);
+ expect(page2.body.pagination.offset).toBe(2);
+ expect(page2.body.pagination.page).toBe(2);
+ });
+
+ it('returns 400 when limit exceeds maximum (50)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?limit=100`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Limit must be between 1 and 50');
+ });
+
+ it('returns 400 when limit is less than 1', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?limit=0`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Limit must be between 1 and 50');
+ });
+
+ it('returns 400 when offset is negative', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?offset=-5`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Offset must be a non-negative number');
+ });
+
+ it('returns 400 when limit is not a valid number', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?limit=abc`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Limit must be between 1 and 50');
+ });
+
+ it('returns 400 when offset is not a valid number', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?offset=xyz`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Offset must be a non-negative number');
+ });
+
+ it('uses default pagination values (limit=10, offset=0)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.pagination.offset).toBe(0);
+ expect(response.body.pagination.limit).toBe(10);
+ });
+
+ it('calculates correct page number from offset and limit', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?offset=20&limit=10`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.pagination.page).toBe(3); // (20 / 10) + 1 = 3
+ });
+ });
+
+ // ============================================
+ // Search Mode (with q parameter)
+ // ============================================
+ describe('Search mode (with q parameter)', function () {
+ it('returns 200 with matching users when query is provided', async function () {
+ const testUser1 = await createTestUser('USER', 'searchuser1');
+ const testUser2 = await createTestUser('USER', 'searchuser2');
+ const testUser3 = await createTestUser('USER', 'otheruser');
+
+ trackUserForCleanup(testUser1);
+ trackUserForCleanup(testUser2);
+ trackUserForCleanup(testUser3);
+
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=searchuser`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('data');
+ expect(response.body).toHaveProperty('pagination');
+ expect(Array.isArray(response.body.data)).toBeTruthy();
+ expect(response.body.data.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('returns only users matching the query (not including non-matching)', async function () {
+ const testUser1 = await createTestUser('USER', 'alphauser');
+ const testUser2 = await createTestUser('USER', 'betauser');
+
+ trackUserForCleanup(testUser1);
+ trackUserForCleanup(testUser2);
+
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=alpha`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.some((u: any) => u.username === 'alphauser')).toBeTruthy();
+ expect(response.body.data.some((u: any) => u.username === 'betauser')).toBeFalsy();
+ });
+
+ it('returns empty array when no users match search', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=nonexistent_user_xyz123`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data).toEqual([]);
+ expect(response.body.pagination.total).toBe(0);
+ });
+
+ it('applies limit to search results', async function () {
+ const testUser1 = await createTestUser('USER', 'test_alpha_1');
+ const testUser2 = await createTestUser('USER', 'test_alpha_2');
+ const testUser3 = await createTestUser('USER', 'test_alpha_3');
+
+ trackUserForCleanup(testUser1);
+ trackUserForCleanup(testUser2);
+ trackUserForCleanup(testUser3);
+
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=test_alpha&limit=2`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.length).toBeLessThanOrEqual(2);
+ });
+
+ it('applies offset to search results', async function () {
+ const testUser1 = await createTestUser('USER', 'query_user_01');
+ const testUser2 = await createTestUser('USER', 'query_user_02');
+ const testUser3 = await createTestUser('USER', 'query_user_03');
+
+ trackUserForCleanup(testUser1);
+ trackUserForCleanup(testUser2);
+ trackUserForCleanup(testUser3);
+
+ const allResults = await request(app)
+ .get(`${baseUrl}/users?q=query_user&limit=100`)
+ .set('x-api-key', adminApiKey);
+
+ const page2 = await request(app)
+ .get(`${baseUrl}/users?q=query_user&offset=1&limit=1`)
+ .set('x-api-key', adminApiKey);
+
+ expect(page2.status).toBe(200);
+ expect(page2.body.pagination.offset).toBe(1);
+ expect(page2.body.pagination.page).toBe(2);
+ });
+
+ it('performs case-insensitive search', async function () {
+ const testUser = await createTestUser('USER', 'CaseSensitiveUser');
+ trackUserForCleanup(testUser);
+
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=casesensitive`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.some((u: any) => u.username === 'CaseSensitiveUser')).toBeTruthy();
+ });
+
+ it('supports partial username matching (regex)', async function () {
+ const testUser = await createTestUser('USER', 'john_developer_123');
+ trackUserForCleanup(testUser);
+
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=develop`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.data.some((u: any) => u.username === 'john_developer_123')).toBeTruthy();
+ });
+
+ it('returns 400 when limit exceeds maximum (50)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=test&limit=75`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Limit must be between 1 and 50');
+ });
+
+ it('returns 400 when offset is negative in search mode', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=test&offset=-1`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toContain('Offset must be a non-negative number');
+ });
+
+ it('includes pagination metadata in search results', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=admin`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.pagination).toEqual({
+ offset: expect.any(Number),
+ limit: expect.any(Number),
+ total: expect.any(Number),
+ page: expect.any(Number),
+ pages: expect.any(Number),
+ });
+ });
+ });
+
+ // ============================================
+ // Empty Query String Edge Case
+ // ============================================
+ describe('Empty query string (q="")', function () {
+ it('treats empty query as list all (pagination mode)', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users?q=`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('data');
+ expect(response.body).toHaveProperty('pagination');
+ });
+ });
+ });
+
+
+ describe('POST /users', function () {
+ it('returns 201 when creating a user with explicit role', async function () {
const userData = {
username: `test_user_${Date.now()}`,
password: 'password123',
role: USER_ROLES[USER_ROLES.length - 1],
};
- const response = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', adminApiKey)
- .send(userData);
+ const response = await request(app).post(`${baseUrl}/users`).send(userData);
expect(response.status).toBe(201);
expect(response.body.username).toBe(userData.username);
expect(response.body.role).toBe(userData.role);
expect(response.body.apiKey).toBeDefined();
+ trackUserForCleanup(response.body);
+
+ const organizations = await organizationService.findByOwner(userData.username);
- testUser = response.body;
+ expect(organizations.length).toBe(1);
+ expect(organizations[0].name).toBe(`${userData.username}'s Organization`);
});
+
+ it('returns 201 when ADMIN tries to create ADMIN', async function () {
+ const userData = {
+ username: `test_user_${Date.now()}`,
+ password: 'password123',
+ role: USER_ROLES[0],
+ };
+
+ const response = await request(app).post(`${baseUrl}/users`).set('x-api-key', adminApiKey).send(userData);
+
+ expect(response.status).toBe(201);
+ expect(response.body.username).toBe(userData.username);
+ expect(response.body.role).toBe(userData.role);
+ expect(response.body.apiKey).toBeDefined();
+ trackUserForCleanup(response.body);
+
+ const organizations = await organizationService.findByOwner(userData.username);
+
+ expect(organizations.length).toBe(1);
+ expect(organizations[0].name).toBe(`${userData.username}'s Organization`);
+ });
+
+ it('returns 201 and assigns default role when role is missing', async function () {
+ const userData = {
+ username: `test_user_${Date.now()}`,
+ password: 'password123',
+ };
+
+ const response = await request(app).post(`${baseUrl}/users`).send(userData);
+
+ expect(response.status).toBe(201);
+ expect(response.body.username).toBe(userData.username);
+ expect(response.body.role).toBe(USER_ROLES[USER_ROLES.length - 1]);
+ expect(response.body.apiKey).toBeDefined();
+ trackUserForCleanup(response.body);
+
+ const organizations = await organizationService.findByOwner(userData.username);
+
+ expect(organizations.length).toBe(1);
+ expect(organizations[0].name).toBe(`${userData.username}'s Organization`);
+ });
+
+ it('returns 201 and creates a default organization for the new user', async function () {
+ const userData = {
+ username: `test_user_${Date.now()}`,
+ password: 'password123',
+ role: USER_ROLES[USER_ROLES.length - 1],
+ };
+
+ const response = await request(app).post(`${baseUrl}/users`).send(userData);
+
+ expect(response.status).toBe(201);
+ expect(response.body.username).toBe(userData.username);
+ expect(response.body.apiKey).toBeDefined();
+ trackUserForCleanup(response.body);
+
+ const organizations = await organizationService.findByOwner(userData.username);
+
+ expect(organizations.length).toBe(1);
+ expect(organizations[0].name).toBe(`${userData.username}'s Organization`);
+ expect(organizations[0].owner).toBe(userData.username);
+ expect(organizations[0].default).toBe(true);
+ });
+
+ it('returns 403 when non-admin tries to create an admin', async function () {
+ const creator = await createTestUser('USER');
+ trackUserForCleanup(creator);
- it('Should NOT create admin user', async function () {
- const creatorData = await createTestUser('MANAGER');
-
const userData = {
username: `test_user_${Date.now()}`,
password: 'password123',
@@ -100,23 +477,48 @@ describe('User API Test Suite', function () {
const response = await request(app)
.post(`${baseUrl}/users`)
- .set('x-api-key', creatorData.apiKey)
+ .set('x-api-key', creator.apiKey)
.send(userData);
expect(response.status).toBe(403);
- expect(response.body.error).toBe("Not enough permissions: Only admins can create other admins.");
+ expect(response.body.error).toBe('PERMISSION ERROR: Only admins can create other admins.');
});
- it('Should get all users', async function () {
- const response = await request(app).get(`${baseUrl}/users`).set('x-api-key', adminApiKey);
+ it('returns 422 when role is invalid', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/users`)
+ .send({ username: `test_user_${Date.now()}`, password: 'password123', role: 'INVALID_ROLE' });
+
+ expect(response.status).toBe(422);
+ expect(response.body.error).toBeDefined();
+ });
- expect(response.status).toBe(200);
- expect(Array.isArray(response.body)).toBe(true);
- expect(response.body.length).toBeGreaterThan(0);
+ it('returns 422 when password is missing', async function () {
+ const response = await request(app)
+ .post(`${baseUrl}/users`)
+ .send({ username: `test_user_${Date.now()}` });
+
+ expect(response.status).toBe(422);
+ expect(response.body.error).toBeDefined();
});
- it('Should get a user by username', async function () {
- testUser = await createTestUser(USER_ROLES[USER_ROLES.length - 1]);
+ it('returns 404 when creating a duplicated username', async function () {
+ const existingUser = await createTestUser('USER');
+ trackUserForCleanup(existingUser);
+
+ const response = await request(app)
+ .post(`${baseUrl}/users`)
+ .send({ username: existingUser.username, password: 'password123' });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('already');
+ });
+ });
+
+ describe('GET /users/:username', function () {
+ it('returns 200 when user exists', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
const response = await request(app)
.get(`${baseUrl}/users/${testUser.username}`)
@@ -126,343 +528,426 @@ describe('User API Test Suite', function () {
expect(response.body.username).toBe(testUser.username);
});
- it('Should update a user', async function () {
- testUser = await createTestUser('MANAGER');
+ it('returns 404 when user does not exist', async function () {
+ const response = await request(app)
+ .get(`${baseUrl}/users/non_existing_user`)
+ .set('x-api-key', adminApiKey);
- const updatedData = {
- username: `updated_${Date.now()}`, // Use timestamp to ensure uniqueness
- };
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
+ });
+
+ it('returns 401 when api key is missing', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
+
+ const response = await request(app).get(`${baseUrl}/users/${testUser.username}`);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
+
+ describe('PUT /users/:username', function () {
+ it('returns 200 when admin updates username', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
+ const updatedUsername = `updated_${Date.now()}`;
const response = await request(app)
.put(`${baseUrl}/users/${testUser.username}`)
.set('x-api-key', adminApiKey)
- .send(updatedData);
+ .send({ username: updatedUsername });
expect(response.status).toBe(200);
- expect(response.body.username).toBe(updatedData.username);
+ expect(response.body.username).toBe(updatedUsername);
+ trackUserForCleanup(response.body);
+ });
+
+ it('returns 404 when target user is not found', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/non_existing_user`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: `updated_${Date.now()}` });
- // Update the test user
- testUser = response.body;
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
});
- it('Should NOT update user to admin', async function () {
- const creatorData = await createTestUser('MANAGER');
- const testAdmin = await createTestUser('ADMIN');
-
- const userData = {
- role: USER_ROLES[0],
- };
+ it('returns 404 when updating username to an existing one', async function () {
+ const firstUser = await createTestUser('USER');
+ const secondUser = await createTestUser('USER');
+ trackUserForCleanup(firstUser);
+ trackUserForCleanup(secondUser);
const response = await request(app)
- .put(`${baseUrl}/users/${testAdmin.username}`)
- .set('x-api-key', creatorData.apiKey)
- .send(userData);
+ .put(`${baseUrl}/users/${firstUser.username}`)
+ .set('x-api-key', adminApiKey)
+ .send({ username: secondUser.username });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toContain('already');
+ });
+
+ it('returns 403 when non-admin tries to promote to admin', async function () {
+ const creator = await createTestUser('USER');
+ const targetUser = await createTestUser('USER');
+ trackUserForCleanup(creator);
+ trackUserForCleanup(targetUser);
+
+ const response = await request(app)
+ .put(`${baseUrl}/users/${targetUser.username}`)
+ .set('x-api-key', creator.apiKey)
+ .send({ role: USER_ROLES[0] });
expect(response.status).toBe(403);
- expect(response.body.error).toBe("Not enough permissions: Only admins can change roles to admin.");
+ expect(response.body.error).toBe('PERMISSION ERROR: Only admins can change roles to admin.');
});
- it('Should NOT update user to admin', async function () {
- const creatorData = await createTestUser('MANAGER');
- const testAdmin = await createTestUser('ADMIN');
-
- const userData = {
- username: `updated_${Date.now()}`,
- };
+ it('returns 403 when non-admin updates an admin', async function () {
+ const creator = await createTestUser('USER');
+ const adminTarget = await createTestUser('ADMIN');
+ trackUserForCleanup(creator);
+ trackUserForCleanup(adminTarget);
const response = await request(app)
- .put(`${baseUrl}/users/${testAdmin.username}`)
- .set('x-api-key', creatorData.apiKey)
- .send(userData);
+ .put(`${baseUrl}/users/${adminTarget.username}`)
+ .set('x-api-key', creator.apiKey)
+ .send({ username: `updated_${Date.now()}` });
expect(response.status).toBe(403);
- expect(response.body.error).toBe("Not enough permissions: Only admins can update admin users.");
+ expect(response.body.error).toBe('PERMISSION ERROR: Only admins can update admin users.');
+
+ await deleteTestUser(adminTarget.username);
});
- it("Should change a user's role", async function () {
- // First create a test user
- testUser = await createTestUser(USER_ROLES[USER_ROLES.length - 1]);
+ it('returns 401 when api key is missing', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
- const newRole = 'MANAGER';
const response = await request(app)
- .put(`${baseUrl}/users/${testUser.username}/role`)
- .set('x-api-key', adminApiKey)
- .send({ role: newRole });
+ .put(`${baseUrl}/users/${testUser.username}`)
+ .send({ username: `updated_${Date.now()}` });
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
+
+ describe('PUT /users/:username/api-key', function () {
+ it('returns 200 and regenerates api key', async function () {
+ const oldApiKey = adminApiKey;
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}/api-key`)
+ .set('x-api-key', oldApiKey);
expect(response.status).toBe(200);
- expect(response.body.username).toBe(testUser.username);
- expect(response.body.role).toBe(newRole);
+ expect(response.body.apiKey).toBeDefined();
+ expect(response.body.apiKey).not.toBe(oldApiKey);
+ adminApiKey = response.body.apiKey;
- // Update the test user
- testUser = response.body;
+ const refreshed = await request(app)
+ .get(`${baseUrl}/users/${adminUser.username}`)
+ .set('x-api-key', adminApiKey);
+ expect(refreshed.status).toBe(200);
});
- it("Should NOT change an admin's role", async function () {
- const creatorData = await createTestUser('MANAGER');
- const adminUser = await createTestUser(USER_ROLES[0]);
+ it('returns 401 when api key is missing', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}/api-key`);
- const newRole = 'MANAGER';
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+
+ it('returns 403 when USER tries to regenerate API Key for another user', async function () {
+
+ const testUser = await createTestUser('USER');
+ const sandboxUser = await createTestUser('USER');
+
const response = await request(app)
- .put(`${baseUrl}/users/${adminUser.username}/role`)
- .set('x-api-key', creatorData.apiKey)
- .send({ role: newRole });
+ .put(`${baseUrl}/users/${sandboxUser.username}/api-key`)
+ .set('x-api-key', testUser.apiKey);
expect(response.status).toBe(403);
- expect(response.body.error).toBe("Not enough permissions: Only admins can update admin users.");
+ expect(response.body.error).toBeDefined();
});
- it("Should NOT change a user's role to ADMIN", async function () {
- const creatorData = await createTestUser('MANAGER');
- const evaluatorUser = await createTestUser(USER_ROLES[USER_ROLES.length - 1]);
+ it('returns 404 when user does not exist', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/non_existing_user/api-key`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
+ });
+ });
- const newRole = 'ADMIN';
+ describe('PUT /users/:username/role', function () {
+ it('returns 200 when admin promotes a user to admin', async function () {
+ const testUser = await createTestUser(USER_ROLES[USER_ROLES.length - 1]);
+ trackUserForCleanup(testUser);
const response = await request(app)
- .put(`${baseUrl}/users/${evaluatorUser.username}/role`)
- .set('x-api-key', creatorData.apiKey)
- .send({ role: newRole });
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .set('x-api-key', adminApiKey)
+ .send({ role: 'ADMIN' });
- expect(response.status).toBe(403);
- expect(response.body.error).toBe("Not enough permissions: Only admins can assign the role ADMIN.");
+ expect(response.status).toBe(200);
+ expect(response.body.role).toBe('ADMIN');
+ trackUserForCleanup(response.body);
});
- it('Should delete a user', async function () {
- // First create a test user
- testUser = await createTestUser(USER_ROLES[USER_ROLES.length - 1]);
+ it('returns 403 when non-admin assigns ADMIN', async function () {
+ const creator = await createTestUser('USER');
+ const targetUser = await createTestUser('USER');
+ trackUserForCleanup(creator);
+ trackUserForCleanup(targetUser);
const response = await request(app)
- .delete(`${baseUrl}/users/${testUser.username}`)
- .set('x-api-key', adminApiKey);
+ .put(`${baseUrl}/users/${targetUser.username}/role`)
+ .set('x-api-key', creator.apiKey)
+ .send({ role: 'ADMIN' });
- expect(response.status).toBe(204);
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('PERMISSION ERROR: Only admins can assign the role ADMIN.');
+ });
- // Try to get the deleted user
- const getResponse = await request(app)
- .get(`${baseUrl}/users/${testUser.username}`)
- .set('x-api-key', adminApiKey);
+ it('returns 403 when non-admin modifies an admin', async function () {
+ const creator = await createTestUser('USER');
+ const adminTarget = await createTestUser('ADMIN');
+ trackUserForCleanup(creator);
+ trackUserForCleanup(adminTarget);
- expect(getResponse.status).toBe(404);
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminTarget.username}/role`)
+ .set('x-api-key', creator.apiKey)
+ .send({ role: USER_ROLES[USER_ROLES.length - 1] });
- // To avoid double cleanup
- testUser = null;
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('PERMISSION ERROR: Only admins can change roles for other users.');
+
+ await deleteTestUser(adminTarget.username);
});
- });
- describe('Role-based Access Control', function () {
- let evaluatorUser: any;
- let managerUser: any;
+ it('returns 403 when trying to demote the last admin', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/${adminUser.username}/role`)
+ .set('x-api-key', adminApiKey)
+ .send({ role: USER_ROLES[USER_ROLES.length - 1] });
- beforeEach(async function () {
- // Create users with different roles
- evaluatorUser = await createTestUser('EVALUATOR');
- managerUser = await createTestUser('MANAGER');
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain('There must always be at least one ADMIN');
});
- afterEach(async function () {
- // Clean up created users
- if (evaluatorUser?.username) await deleteTestUser(evaluatorUser.username);
- if (managerUser?.username) await deleteTestUser(managerUser.username);
- });
+ it('returns 422 when role is invalid', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
- describe('EVALUATOR Role', function () {
- it('EVALUATOR user should be able to access GET /services endpoint', async function () {
- const getServicesResponse = await request(app)
- .get(`${baseUrl}/services`)
- .set('x-api-key', evaluatorUser.apiKey);
+ const response = await request(app)
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .set('x-api-key', adminApiKey)
+ .send({ role: 'INVALID_ROLE' });
- expect(getServicesResponse.status).toBe(200);
- });
+ expect(response.status).toBe(422);
+ expect(response.body.error).toBeDefined();
+ });
- it('EVALUATOR user should be able to access GET /features endpoint', async function () {
- const getFeaturesResponse = await request(app)
- .get(`${baseUrl}/features`)
- .set('x-api-key', evaluatorUser.apiKey);
+ it('returns 404 when user does not exist', async function () {
+ const response = await request(app)
+ .put(`${baseUrl}/users/non_existing_user/role`)
+ .set('x-api-key', adminApiKey)
+ .send({ role: USER_ROLES[USER_ROLES.length - 1] });
- expect(getFeaturesResponse.status).toBe(200);
- });
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
+ });
- it('EVALUATOR user should NOT be able to access GET /users endpoint', async function () {
- const getUsersResponse = await request(app)
- .get(`${baseUrl}/users`)
- .set('x-api-key', evaluatorUser.apiKey);
+ it('returns 401 when api key is missing', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
- expect(getUsersResponse.status).toBe(403);
- });
+ const response = await request(app)
+ .put(`${baseUrl}/users/${testUser.username}/role`)
+ .send({ role: USER_ROLES[USER_ROLES.length - 1] });
- it('EVALUATOR user should be able to use POST operations on /features endpoint', async function () {
- const newContract = await createRandomContract(app);
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
+ });
- const postFeaturesResponse = await request(app)
- .post(`${baseUrl}/features/${newContract.userContact.userId}`)
- .set('x-api-key', evaluatorUser.apiKey);
+ describe('DELETE /users/:username', function () {
+ it('returns 204 when admin deletes a user', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
- expect(postFeaturesResponse.status).toBe(200);
- });
+ const response = await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey);
- it('EVALUATOR user should NOT be able to use POST operations on /users endpoint', async function () {
- const postUsersResponse = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', evaluatorUser.apiKey)
- .send({
- username: `test_user_${Date.now()}`,
- password: 'password123',
- role: USER_ROLES[USER_ROLES.length - 1],
- });
+ expect(response.status).toBe(204);
- expect(postUsersResponse.status).toBe(403);
- });
+ const getResponse = await request(app)
+ .get(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey);
+ expect(getResponse.status).toBe(404);
+ });
+
+ it('returns 204 when deleting a user and remove organization', async function () {
+ const testUser = await createTestUser('USER');
+ const testOrg = await createTestOrganization(testUser.username);
+
+ trackUserForCleanup(testUser);
+ trackOrganizationForCleanup(testOrg);
- it('EVALUATOR user should NOT be able to use PUT operations on /users endpoint', async function () {
- const putUsersResponse = await request(app)
- .put(`${baseUrl}/users/${evaluatorUser.username}`)
- .set('x-api-key', evaluatorUser.apiKey)
- .send({
- username: `updated_${Date.now()}`,
- });
+ await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey).expect(204);
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey);
- expect(putUsersResponse.status).toBe(403);
- });
+ expect(response.status).toBe(404);
+ });
+
+ it('returns 204 when deleting a user and transfer organization ownership to ADMIN', async function () {
+ const testUser = await createTestUser('USER');
+ const testUserAdmin = await createTestUser('USER');
+ const testUserManager = await createTestUser('USER');
+ const testOrg = await createTestOrganization(testUser.username);
+ await addMemberToOrganization(testOrg.id!, { username: testUserAdmin.username, role: 'ADMIN' });
+ await addMemberToOrganization(testOrg.id!, { username: testUserManager.username, role: 'MANAGER' });
+
+ trackUserForCleanup(testUser);
+ trackUserForCleanup(testUserAdmin);
+ trackUserForCleanup(testUserManager);
+ trackOrganizationForCleanup(testOrg);
- it('EVALUATOR user should NOT be able to use DELETE operations on /users endpoint', async function () {
- const deleteUsersResponse = await request(app)
- .delete(`${baseUrl}/users/${evaluatorUser.username}`)
- .set('x-api-key', evaluatorUser.apiKey);
-
- expect(deleteUsersResponse.status).toBe(403);
- });
+ await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey).expect(204);
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey);
+
+ expect(response.status).toBe(200);
+ expect(response.body.owner).toBe(testUserAdmin.username);
});
+
+ it('returns 204 when deleting a user and transfer organization ownership to MANAGER', async function () {
+ const testUser = await createTestUser('USER');
+ const testUserManager = await createTestUser('USER');
+ const testUserEvaluator = await createTestUser('USER');
+ const testOrg = await createTestOrganization(testUser.username);
+ await addMemberToOrganization(testOrg.id!, { username: testUserManager.username, role: 'MANAGER' });
+ await addMemberToOrganization(testOrg.id!, { username: testUserEvaluator.username, role: 'EVALUATOR' });
+
+ trackUserForCleanup(testUser);
+ trackUserForCleanup(testUserManager);
+ trackUserForCleanup(testUserEvaluator);
+ trackOrganizationForCleanup(testOrg);
- describe('MANAGER Role', function () {
- it('MANAGER user should be able to access GET /services endpoint', async function () {
- const response = await request(app)
- .get(`${baseUrl}/services`)
- .set('x-api-key', managerUser.apiKey);
+ await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey).expect(204);
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey);
- expect(response.status).toBe(200);
- });
+ expect(response.status).toBe(200);
+ expect(response.body.owner).toBe(testUserManager.username);
+ });
+
+ it('returns 204 when deleting a user and transfer organization ownership to EVALUATOR', async function () {
+ const testUser = await createTestUser('USER');
+ const testUserEvaluator = await createTestUser('USER');
+ const testOrg = await createTestOrganization(testUser.username);
+ await addMemberToOrganization(testOrg.id!, { username: testUserEvaluator.username, role: 'EVALUATOR' });
+
+ trackUserForCleanup(testUser);
+ trackUserForCleanup(testUserEvaluator);
+ trackOrganizationForCleanup(testOrg);
- it('MANAGER user should be able to access GET /users endpoint', async function () {
- const response = await request(app)
- .get(`${baseUrl}/users`)
- .set('x-api-key', managerUser.apiKey);
+ await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey).expect(204);
+
+ const response = await request(app)
+ .get(`${baseUrl}/organizations/${testOrg.id}`)
+ .set('x-api-key', adminApiKey);
- expect(response.status).toBe(200);
- });
+ expect(response.status).toBe(200);
+ expect(response.body.owner).toBe(testUserEvaluator.username);
+ });
+
+ it('returns 204 when deleting a user, removing organization and transfer organization ownership to EVALUATOR', async function () {
+ const testUser = await createTestUser('USER');
+ const testUserAdmin = await createTestUser('USER');
+ const testOrg1 = await createTestOrganization(testUser.username);
+ const testOrg2 = await createTestOrganization(testUser.username);
+ await addMemberToOrganization(testOrg2.id!, { username: testUserAdmin.username, role: 'ADMIN' });
+
+ trackUserForCleanup(testUser);
+ trackUserForCleanup(testUserAdmin);
+ trackOrganizationForCleanup(testOrg1);
+ trackOrganizationForCleanup(testOrg2);
- it('MANAGER user should be able to use POST operations on /users endpoint', async function () {
- const userData = {
- username: `test_user_${Date.now()}`,
- password: 'password123',
- role: USER_ROLES[USER_ROLES.length - 1],
- }
+ await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`)
+ .set('x-api-key', adminApiKey).expect(204);
+
+ const responseOrg1 = await request(app)
+ .get(`${baseUrl}/organizations/${testOrg1.id}`)
+ .set('x-api-key', adminApiKey);
- const response = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', managerUser.apiKey)
- .send(userData);
+ expect(responseOrg1.status).toBe(404);
- expect(response.status).toBe(201);
- });
+ const responseOrg2 = await request(app)
+ .get(`${baseUrl}/organizations/${testOrg2.id}`)
+ .set('x-api-key', adminApiKey);
- it('MANAGER user should NOT be able to create ADMIN users', async function () {
- const userData = {
- username: `test_user_${Date.now()}`,
- password: 'password123',
- role: USER_ROLES[0], // ADMIN role
- }
+ expect(responseOrg2.status).toBe(200);
+ expect(responseOrg2.body.owner).toBe(testUserAdmin.username);
+ });
- const response = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', managerUser.apiKey)
- .send(userData);
+ it('returns 404 when deleting a non-existent user', async function () {
+ const response = await request(app)
+ .delete(`${baseUrl}/users/non_existing_user`)
+ .set('x-api-key', adminApiKey);
- expect(response.status).toBe(403);
- });
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBeDefined();
+ });
- it('MANAGER user should be able to use PUT operations on /users endpoint', async function () {
- // First create a service to update
- const userData = {
- username: `test_user_${Date.now()}`,
- password: 'password123',
- role: USER_ROLES[USER_ROLES.length - 1],
- }
-
- const createResponse = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', adminApiKey)
- .send(userData);
-
- const username = createResponse.body.username;
-
- // Test update operation
- const updateData = {
- username: `updated_${Date.now()}`,
- };
-
- const response = await request(app)
- .put(`${baseUrl}/users/${username}`)
- .set('x-api-key', managerUser.apiKey)
- .send(updateData);
-
- expect(response.status).toBe(200);
- });
+ it('returns 403 when non-admin tries to delete an admin', async function () {
+ const regularUser = await createTestUser('USER');
+ const targetAdmin = await createTestUser('ADMIN');
+ trackUserForCleanup(regularUser);
+ trackUserForCleanup(targetAdmin);
- it('MANAGER user should NOT be able to use DELETE operations', async function () {
- const response = await request(app)
- .delete(`${baseUrl}/services/1234`)
- .set('x-api-key', managerUser.apiKey);
+ const response = await request(app)
+ .delete(`${baseUrl}/users/${targetAdmin.username}`)
+ .set('x-api-key', regularUser.apiKey);
- expect(response.status).toBe(403);
- });
- })
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('PERMISSION ERROR: Only admins can delete admin users.');
- describe('ADMIN Role', function () {
- it('ADMIN user should have GET access to user endpoints', async function () {
- const getResponse = await request(app).get(`${baseUrl}/users`).set('x-api-key', adminApiKey);
- expect(getResponse.status).toBe(200);
- });
+ await deleteTestUser(targetAdmin.username);
+ });
- it('ADMIN user should have POST access to create users', async function () {
- const userData = {
- username: `new_user_${Date.now()}`,
- password: 'password123',
- role: USER_ROLES[USER_ROLES.length - 1],
- };
-
- const postResponse = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', adminApiKey)
- .send(userData);
-
- expect(postResponse.status).toBe(201);
-
- // Clean up
- await request(app)
- .delete(`${baseUrl}/users/${postResponse.body.username}`)
- .set('x-api-key', adminApiKey);
- });
+ it('returns 401 when api key is missing', async function () {
+ const testUser = await createTestUser('USER');
+ trackUserForCleanup(testUser);
- it('ADMIN user should have DELETE access to remove users', async function () {
- // First create a user to delete
- const userData = {
- username: `delete_user_${Date.now()}`,
- password: 'password123',
- role: USER_ROLES[USER_ROLES.length - 1],
- };
-
- const createResponse = await request(app)
- .post(`${baseUrl}/users`)
- .set('x-api-key', adminApiKey)
- .send(userData);
-
- // Then test deletion
- const deleteResponse = await request(app)
- .delete(`${baseUrl}/users/${createResponse.body.username}`)
- .set('x-api-key', adminApiKey);
-
- expect(deleteResponse.status).toBe(204);
- });
- })
+ const response = await request(app)
+ .delete(`${baseUrl}/users/${testUser.username}`);
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toContain('API Key');
+ });
});
});
diff --git a/api/src/test/utils/auth.ts b/api/src/test/utils/auth.ts
index 73b7f19..a346f0c 100644
--- a/api/src/test/utils/auth.ts
+++ b/api/src/test/utils/auth.ts
@@ -1,7 +1,6 @@
import { createTestUser, deleteTestUser } from './users/userTestUtils';
import { Server } from 'http';
import request from 'supertest';
-import { baseUrl } from './testApp';
// Admin user for testing
let testAdminUser: any = null;
diff --git a/api/src/test/utils/contracts/contractTestUtils.ts b/api/src/test/utils/contracts/contractTestUtils.ts
new file mode 100644
index 0000000..97beb81
--- /dev/null
+++ b/api/src/test/utils/contracts/contractTestUtils.ts
@@ -0,0 +1,257 @@
+import { faker } from '@faker-js/faker';
+import { ContractToCreate, LeanContract, UsageLevel } from '../../../main/types/models/Contract';
+import { baseUrl, getApp, useApp } from '../testApp';
+import request from 'supertest';
+import { generateContract, generateContractAndService } from './generators';
+import { TestContract } from '../../types/models/Contract';
+import { getTestAdminApiKey } from '../auth';
+import { LeanService } from '../../../main/types/models/Service';
+import { createMultipleTestServices } from '../services/serviceTestUtils';
+import { LeanUser } from '../../../main/types/models/User';
+import { createTestUser } from '../users/userTestUtils';
+
+async function createTestContract(organizationId: string, services: LeanService[], app: any, groupId?: string): Promise {
+ if (!app){
+ app = await getApp();
+ }
+
+ if (services.length === 0) {
+ services = await createMultipleTestServices(3, organizationId);
+ }
+
+ const contractedServices: Record = services.reduce(
+ (acc, service) => {
+ acc[service.name] = service.activePricings.keys().next().value!;
+ return acc;
+ },
+ {} as Record
+ );
+
+ const contractData: ContractToCreate = await generateContract(contractedServices, organizationId, undefined, app, groupId);
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
+
+ try{
+
+ const response = await request(app)
+ .post(`${baseUrl}/organizations/${organizationId}/contracts`)
+ .set('x-api-key', apiKey)
+ .send(contractData);
+
+ return response.body as unknown as LeanContract;
+ }catch(error){
+ console.error('Error creating test contract:', error);
+ throw error;
+ }
+}
+
+async function getAllContracts(app?: any): Promise {
+ const copyApp = await useApp(app);
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
+
+ const response = await request(copyApp).get(`${baseUrl}/contracts`).set('x-api-key', apiKey);
+
+ if (response.status !== 200) {
+ throw new Error(
+ `Failed to fetch contracts. Status: ${response.status}. Body: ${response.body}`
+ );
+ }
+
+ return response.body;
+}
+
+async function getContractByUserId(userId: string, app?: any): Promise {
+ const copyApp = await useApp(app);
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
+
+ const response = await request(copyApp)
+ .get(`${baseUrl}/contracts/${userId}`)
+ .set('x-api-key', apiKey)
+ .expect(200);
+
+ return response.body;
+}
+
+async function getRandomContract(app?: any): Promise {
+ const contracts = await getAllContracts(app);
+
+ const randomIndex = faker.number.int({ min: 0, max: contracts.length - 1 });
+
+ return contracts[randomIndex];
+}
+
+async function createRandomContract(organizationId: string, app?: any): Promise {
+ const copyApp = await useApp(app);
+ const apiKey = await getTestAdminApiKey();
+
+ const { contract } = await generateContractAndService(organizationId, undefined, copyApp);
+
+ const response = await request(copyApp)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', apiKey)
+ .send(contract);
+
+ if (response.status !== 201) {
+ throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
+ }
+
+ return response.body;
+}
+
+async function createRandomContracts(
+ organizationId: string,
+ amount: number,
+ app?: any
+): Promise {
+ const copyApp = await useApp(app);
+ const apiKey = await getTestAdminApiKey();
+
+ const createdContracts: TestContract[] = [];
+
+ const { contract, services } = await generateContractAndService(
+ organizationId,
+ undefined,
+ copyApp
+ );
+
+ let response = await request(copyApp)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', apiKey)
+ .send(contract);
+
+ if (response.status !== 201) {
+ throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
+ }
+
+ createdContracts.push(response.body);
+
+ for (let i = 0; i < amount - 1; i++) {
+ const generatedContract = await generateContract(services, organizationId, undefined, copyApp);
+
+ response = await request(copyApp)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', apiKey)
+ .send(generatedContract);
+
+ if (response.status !== 201) {
+ throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
+ }
+
+ createdContracts.push(response.body);
+ }
+
+ return createdContracts;
+}
+
+async function createRandomContractsForService(
+ organizationId: string,
+ serviceName: string,
+ pricingVersion: string,
+ amount: number,
+ app?: any
+): Promise {
+ const copyApp = await useApp(app);
+ const apiKey = await getTestAdminApiKey();
+
+ const createdContracts: TestContract[] = [];
+
+ for (let i = 0; i < amount - 1; i++) {
+ const generatedContract = await generateContract(
+ { [serviceName]: pricingVersion },
+ organizationId,
+ undefined,
+ copyApp
+ );
+
+ const response = await request(copyApp)
+ .post(`${baseUrl}/contracts`)
+ .set('x-api-key', apiKey)
+ .send(generatedContract);
+
+ if (response.status !== 201) {
+ throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
+ }
+
+ createdContracts.push(response.body);
+ }
+
+ return createdContracts;
+}
+
+async function incrementUsageLevel(
+ userId: string,
+ serviceName: string,
+ usageLimitName: string,
+ app?: any
+): Promise {
+ const copyApp = await useApp(app);
+ const apiKey = await getTestAdminApiKey();
+
+ const response = await request(copyApp)
+ .put(`${baseUrl}/contracts/${userId}/usageLevels`)
+ .set('x-api-key', apiKey)
+ .send({
+ [serviceName]: {
+ [usageLimitName]: 5,
+ },
+ })
+ .expect(200);
+
+ return response.body;
+}
+
+async function incrementAllUsageLevel(
+ userId: string,
+ usageLevels: Record>,
+ app?: any
+): Promise {
+ const copyApp = await useApp(app);
+ const apiKey = await getTestAdminApiKey();
+
+ const updatedUsageLevels = Object.keys(usageLevels).reduce(
+ (acc, serviceName) => {
+ acc[serviceName] = Object.keys(usageLevels[serviceName]).reduce(
+ (innerAcc, usageLimitName) => {
+ innerAcc[usageLimitName] = 5;
+ return innerAcc;
+ },
+ {} as Record
+ );
+ return acc;
+ },
+ {} as Record>
+ );
+
+ const response = await request(copyApp)
+ .put(`${baseUrl}/contracts/${userId}/usageLevels`)
+ .set('x-api-key', apiKey)
+ .send(updatedUsageLevels)
+ .expect(200);
+
+ return response.body;
+}
+
+async function deleteTestContract(userId: string, app?: any): Promise {
+ const copyApp = await useApp(app);
+ const apiKey = await getTestAdminApiKey();
+
+ await request(copyApp)
+ .delete(`${baseUrl}/contracts/${userId}`)
+ .set('x-api-key', apiKey)
+ .expect(204);
+}
+
+export {
+ createTestContract,
+ createRandomContracts,
+ getContractByUserId,
+ getAllContracts,
+ getRandomContract,
+ createRandomContract,
+ createRandomContractsForService,
+ incrementAllUsageLevel,
+ incrementUsageLevel,
+ deleteTestContract
+};
diff --git a/api/src/test/utils/contracts/contracts.ts b/api/src/test/utils/contracts/contracts.ts
deleted file mode 100644
index 1534921..0000000
--- a/api/src/test/utils/contracts/contracts.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import { faker } from '@faker-js/faker';
-import { ContractToCreate, UsageLevel } from '../../../main/types/models/Contract';
-import { baseUrl, getApp, useApp } from '../testApp';
-import request from 'supertest';
-import { generateContract, generateContractAndService } from './generators';
-import { TestContract } from '../../types/models/Contract';
-import { getTestAdminApiKey } from '../auth';
-
-async function getAllContracts(app?: any): Promise {
-
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const response = await request(copyApp)
- .get(`${baseUrl}/contracts`)
- .set('x-api-key', apiKey);
-
- if (response.status !== 200) {
- throw new Error(`Failed to fetch contracts. Status: ${response.status}. Body: ${response.body}`);
- }
-
- return response.body;
-}
-
-async function getContractByUserId(userId: string, app?: any): Promise {
-
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const response = await request(copyApp)
- .get(`${baseUrl}/contracts/${userId}`)
- .set('x-api-key', apiKey)
- .expect(200);
-
- return response.body;
-}
-
-async function getRandomContract(app?: any): Promise {
-
- const contracts = await getAllContracts(app);
-
- const randomIndex = faker.number.int({ min: 0, max: contracts.length - 1 });
-
- return contracts[randomIndex];
-}
-
-async function createRandomContract(app?: any): Promise {
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const {contract} = await generateContractAndService(undefined, copyApp);
-
- const response = await request(copyApp)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', apiKey)
- .send(contract);
-
- if (response.status !== 201) {
- throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
- }
-
- return response.body;
-}
-
-async function createRandomContracts(amount: number, app?: any): Promise {
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const createdContracts: TestContract[] = [];
-
- const {contract, services} = await generateContractAndService(undefined, copyApp);
-
- let response = await request(copyApp)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', apiKey)
- .send(contract);
-
- if (response.status !== 201) {
- throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
- }
-
- createdContracts.push(response.body);
-
- for (let i = 0; i < amount - 1; i++) {
- const generatedContract = await generateContract(services, undefined, copyApp);
-
- response = await request(copyApp)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', apiKey)
- .send(generatedContract);
-
- if (response.status !== 201) {
- throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
- }
-
- createdContracts.push(response.body);
- }
-
- return createdContracts;
-}
-
-async function createRandomContractsForService(serviceName: string, pricingVersion: string, amount: number, app?: any): Promise {
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const createdContracts: TestContract[] = [];
-
- for (let i = 0; i < amount - 1; i++) {
- const generatedContract = await generateContract({ [serviceName]: pricingVersion }, undefined, copyApp);
-
- const response = await request(copyApp)
- .post(`${baseUrl}/contracts`)
- .set('x-api-key', apiKey)
- .send(generatedContract);
-
- if (response.status !== 201) {
- throw new Error(`Failed to create contract. Body: ${JSON.stringify(response.body)}`);
- }
-
- createdContracts.push(response.body);
- }
-
- return createdContracts;
-}
-
-async function incrementUsageLevel(userId: string, serviceName: string, usageLimitName: string, app?: any): Promise {
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const response = await request(copyApp)
- .put(`${baseUrl}/contracts/${userId}/usageLevels`)
- .set('x-api-key', apiKey)
- .send({
- [serviceName]: {
- [usageLimitName]: 5
- }
- })
- .expect(200);
-
- return response.body;
-}
-
-async function incrementAllUsageLevel(userId: string, usageLevels: Record>, app?: any): Promise {
- const copyApp = await useApp(app);
- const apiKey = await getTestAdminApiKey();
-
- const updatedUsageLevels = Object.keys(usageLevels).reduce((acc, serviceName) => {
- acc[serviceName] = Object.keys(usageLevels[serviceName]).reduce((innerAcc, usageLimitName) => {
- innerAcc[usageLimitName] = 5;
- return innerAcc;
- }, {} as Record);
- return acc;
- }, {} as Record>);
-
- const response = await request(copyApp)
- .put(`${baseUrl}/contracts/${userId}/usageLevels`)
- .set('x-api-key', apiKey)
- .send(updatedUsageLevels)
- .expect(200);
-
- return response.body;
-}
-
-export { createRandomContracts, getContractByUserId, getAllContracts, getRandomContract, createRandomContract, createRandomContractsForService, incrementAllUsageLevel, incrementUsageLevel };
\ No newline at end of file
diff --git a/api/src/test/utils/contracts/generators.ts b/api/src/test/utils/contracts/generators.ts
index b018165..7b238d6 100644
--- a/api/src/test/utils/contracts/generators.ts
+++ b/api/src/test/utils/contracts/generators.ts
@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker';
-import { createRandomService, getAllServices, getPricingFromService } from '../services/service';
+import { createRandomService, getAllServices, getPricingFromService } from '../services/serviceTestUtils';
import { TestService } from '../../types/models/Service';
import { TestAddOn, TestPricing } from '../../types/models/Pricing';
import { useApp } from '../testApp';
@@ -7,6 +7,7 @@ import { ContractToCreate } from '../../../main/types/models/Contract';
import { biasedRandomInt } from '../random';
async function generateContractAndService(
+ organizationId: string,
userId?: string,
app?: any
): Promise<{ contract: ContractToCreate; services: Record }> {
@@ -14,15 +15,17 @@ async function generateContractAndService(
const contractedServices: Record = await _generateNewContractedServices(appCopy);
- const contract = await generateContract(contractedServices, userId, appCopy);
+ const contract = await generateContract(contractedServices, organizationId,userId, appCopy);
return { contract, services: contractedServices };
}
async function generateContract(
contractedServices: Record,
+ organizationId: string,
userId?: string,
- app?: any
+ app?: any,
+ groupId?: string
): Promise {
const appCopy = await useApp(app);
@@ -37,12 +40,14 @@ async function generateContract(
const subscriptionPlans: Record = await _generateSubscriptionPlans(
servicesToConsider,
+ organizationId,
appCopy
);
const subscriptionAddOns = await _generateSubscriptionAddOns(
servicesToConsider,
subscriptionPlans,
+ organizationId,
appCopy
);
@@ -59,25 +64,28 @@ async function generateContract(
autoRenew: faker.datatype.boolean(),
renewalDays: faker.helpers.arrayElement([30, 365]),
},
+ organizationId: organizationId,
+ groupId: groupId,
contractedServices: contractedServices,
subscriptionPlans: subscriptionPlans,
subscriptionAddOns: subscriptionAddOns,
};
}
-async function generateNovation(app?: any) {
+async function generateNovation(organizationId: string, app?: any) {
const appCopy = await useApp(app);
const contractedServices: Record =
await _generateExistentContractedServices(appCopy);
const subscriptionPlans: Record = await _generateSubscriptionPlans(
contractedServices,
+ organizationId,
appCopy
);
const subscriptionAddOns: Record<
string,
Record
- > = await _generateSubscriptionAddOns(contractedServices, subscriptionPlans, appCopy);
+ > = await _generateSubscriptionAddOns(contractedServices, subscriptionPlans, organizationId, appCopy);
return {
contractedServices: contractedServices,
@@ -86,13 +94,13 @@ async function generateNovation(app?: any) {
};
}
-async function _generateNewContractedServices(app?: any): Promise> {
+async function _generateNewContractedServices(organizationId: string, app?: any): Promise> {
const appCopy = await useApp(app);
const contractedServices: Record = {};
for (let i = 0; i < biasedRandomInt(1, 3); i++) {
- const createdService: TestService = await createRandomService(appCopy);
+ const createdService: TestService = await createRandomService(organizationId, appCopy);
const pricingVersion = Object.keys(createdService.activePricings)[0];
contractedServices[createdService.name] = pricingVersion;
}
@@ -100,11 +108,11 @@ async function _generateNewContractedServices(app?: any): Promise> {
+async function _generateExistentContractedServices(organizationId: string, app?: any): Promise> {
const appCopy = await useApp(app);
const contractedServices: Record = {};
- const services = await getAllServices(appCopy);
+ const services = await getAllServices(organizationId, appCopy);
const randomServices = faker.helpers.arrayElements(
services,
@@ -121,6 +129,7 @@ async function _generateExistentContractedServices(app?: any): Promise,
+ organizationId: string,
app?: any
): Promise> {
const appCopy = await useApp(app);
@@ -131,6 +140,7 @@ async function _generateSubscriptionPlans(
const pricing = await getPricingFromService(
serviceName,
contractedServices[serviceName],
+ organizationId,
appCopy
);
@@ -145,6 +155,7 @@ async function _generateSubscriptionPlans(
async function _generateSubscriptionAddOns(
contractedServices: Record,
subscriptionPlans: Record,
+ organizationId: string,
app?: any
): Promise>> {
const appCopy = await useApp(app);
@@ -157,6 +168,7 @@ async function _generateSubscriptionAddOns(
const pricing = await getPricingFromService(
serviceName,
contractedServices[serviceName],
+ organizationId,
appCopy
);
diff --git a/api/src/test/utils/organization/organizationTestUtils.ts b/api/src/test/utils/organization/organizationTestUtils.ts
new file mode 100644
index 0000000..f54f99a
--- /dev/null
+++ b/api/src/test/utils/organization/organizationTestUtils.ts
@@ -0,0 +1,61 @@
+import { LeanApiKey, LeanOrganization, OrganizationMember } from '../../../main/types/models/Organization';
+import { createTestUser } from '../users/userTestUtils';
+import OrganizationMongoose from '../../../main/repositories/mongoose/models/OrganizationMongoose';
+import container from '../../../main/config/container';
+
+// Create a test user directly in the database
+export const createTestOrganization = async (owner?: string): Promise => {
+
+ if (!owner){
+ owner = (await createTestUser('ADMIN')).username;
+ }
+
+ const organizationData = {
+ name: `test_org_${Date.now()}`,
+ owner: owner,
+ apiKeys: [],
+ members: [],
+ };
+
+ // Create user directly in the database
+ const organization = new OrganizationMongoose(organizationData);
+ await organization.save();
+
+ return organization.toObject();
+};
+
+export const addApiKeyToOrganization = async (orgId: string, apiKey: LeanApiKey): Promise => {
+ const organizationRepository = container.resolve('organizationRepository');
+
+ await organizationRepository.addApiKey(orgId, apiKey);
+};
+
+export const addMemberToOrganization = async (orgId: string, organizationMember: OrganizationMember): Promise => {
+ if (!organizationMember.username || !organizationMember.role){
+ throw new Error('Both username and role are required to add a member to an organization.');
+ }
+
+ const organizationRepository = container.resolve('organizationRepository');
+
+ try{
+ await organizationRepository.addMember(orgId, organizationMember);
+ }catch (error){
+ console.log(`Error adding member ${organizationMember.username} to organization ${orgId}:`, error);
+ }
+};
+
+export const removeApiKeyFromOrganization = async (orgId: string, apiKey: string): Promise => {
+ const organizationRepository = container.resolve('organizationRepository');
+
+ await organizationRepository.removeApiKey(orgId, apiKey);
+};
+
+export const removeMemberFromOrganization = async (orgId: string, username: string): Promise => {
+ const organizationRepository = container.resolve('organizationRepository');
+
+ await organizationRepository.removeMember(orgId, username);
+};
+
+export const deleteTestOrganization = async (orgId: string): Promise => {
+ await OrganizationMongoose.deleteOne({ _id: orgId });
+};
\ No newline at end of file
diff --git a/api/src/test/utils/regex.ts b/api/src/test/utils/regex.ts
new file mode 100644
index 0000000..c0521c5
--- /dev/null
+++ b/api/src/test/utils/regex.ts
@@ -0,0 +1,26 @@
+export function getFirstPlanFromPricing(pricing: string){
+ const regex = /plans:\s*(?:\r\n|\n|\r)\s+([^\s:]+)/;
+ const match = pricing.match(regex);
+ if (match && match[1]){
+ return match[1];
+ }
+ throw new Error('No plan name found in pricing');
+}
+
+export function getServiceNameFromPricing(pricing: string){
+ const regex = /saasName:\s*([^\s]+)/;
+ const match = pricing.match(regex);
+ if (match && match[1]){
+ return match[1];
+ }
+ throw new Error('No service name found in pricing');
+}
+
+export function getVersionFromPricing(pricing: string){
+ const regex = /version:\s*([^\s]+)/;
+ const match = pricing.match(regex);
+ if (match && match[1]){
+ return match[1];
+ }
+ throw new Error('No version found in pricing');
+}
\ No newline at end of file
diff --git a/api/src/test/utils/services/pricing.ts b/api/src/test/utils/services/pricingTestUtils.ts
similarity index 99%
rename from api/src/test/utils/services/pricing.ts
rename to api/src/test/utils/services/pricingTestUtils.ts
index d95c22a..e369471 100644
--- a/api/src/test/utils/services/pricing.ts
+++ b/api/src/test/utils/services/pricingTestUtils.ts
@@ -14,6 +14,11 @@ import fs from 'fs';
import { biasedRandomInt } from '../random';
export async function generatePricingFile(serviceName?: string, version?: string): Promise {
+
+ if (!version){
+ version = uuidv4();
+ }
+
let pricing: TestPricing & { saasName?: string; syntaxVersion?: string } =
generatePricing(version);
if (serviceName) {
@@ -32,7 +37,7 @@ export async function generatePricingFile(serviceName?: string, version?: string
lineWidth: -1, // do not cut lines
});
- const filePath = path.resolve(__dirname, `../../data/generated/${uuidv4()}.yaml`);
+ const filePath = path.resolve(__dirname, `../../data/generated/${version}.yaml`);
if (!fs.existsSync(path.dirname(filePath))) {
await mkdir(path.dirname(filePath), { recursive: true });
diff --git a/api/src/test/utils/services/service.ts b/api/src/test/utils/services/serviceTestUtils.ts
similarity index 54%
rename from api/src/test/utils/services/service.ts
rename to api/src/test/utils/services/serviceTestUtils.ts
index 43c16dd..e1ef38b 100644
--- a/api/src/test/utils/services/service.ts
+++ b/api/src/test/utils/services/serviceTestUtils.ts
@@ -2,25 +2,96 @@ import fs from 'fs';
import request from 'supertest';
import { baseUrl, getApp, useApp } from '../testApp';
import { clockifyPricingPath, githubPricingPath, zoomPricingPath } from './ServiceTestData';
-import { generatePricingFile } from './pricing';
+import { generatePricingFile } from './pricingTestUtils';
import { v4 as uuidv4 } from 'uuid';
import { TestService } from '../../types/models/Service';
import { TestPricing } from '../../types/models/Pricing';
import { getTestAdminApiKey } from '../auth';
+import { createTestOrganization } from '../organization/organizationTestUtils';
+import { LeanService } from '../../../main/types/models/Service';
+import container from '../../../main/config/container';
+import { createTestUser } from '../users/userTestUtils';
+import { LeanUser } from '../../../main/types/models/User';
function getRandomPricingFile(name?: string) {
return generatePricingFile(name);
}
-async function getAllServices(app?: any): Promise {
+async function createMultipleTestServices(amount: number, organizationId?: string): Promise {
+ const services: LeanService[] = [];
+
+ for (let i = 0; i < amount; i++) {
+ const service = await createTestService(organizationId);
+ services.push(service);
+ }
+
+ return services;
+
+}
+
+async function createTestService(organizationId?: string, serviceName?: string): Promise {
+
+ if (!serviceName){
+ serviceName = `test-service-${Date.now()}`;
+ }
+
+ if (!organizationId){
+ const testOrganization = await createTestOrganization();
+ organizationId = testOrganization.id!;
+ }
+
+ const enabledPricingPath = await generatePricingFile(serviceName);
+ const serviceService = container.resolve('serviceService');
+
+ const service = await serviceService.create({path: enabledPricingPath}, "file", organizationId);
+
+ return service as unknown as LeanService;
+}
+
+async function addArchivedPricingToService(organizationId: string, serviceName: string, version?: string,returnContent: boolean = false): Promise {
+ const pricingPath = await generatePricingFile(serviceName, version);
+ const pricingContent = fs.readFileSync(pricingPath, 'utf-8');
+ const regex = /plans:\s*(?:\r\n|\n|\r)\s+([^\s:]+)/;
+ const fallbackPlan = pricingContent.match(regex)?.[1];
+
+ const serviceService = container.resolve('serviceService');
+ const updatedService = await serviceService.addPricingToService(serviceName!, {path: pricingPath}, "file", organizationId!);
+
+ const pricingToArchive = pricingPath.split('/').pop()!.replace('.yaml', '');
+
+ if (!pricingToArchive) {
+ throw new Error('No pricing found to archive');
+ }
+
+ await serviceService.updatePricingAvailability(serviceName, pricingToArchive, "archived", {subscriptionPlan: fallbackPlan}, organizationId);
+
+ return returnContent ? pricingContent : pricingToArchive;
+}
+
+async function addPricingToService(organizationId?: string, serviceName?: string, version?: string, returnContent: boolean = false): Promise {
+ const pricingPath = await generatePricingFile(serviceName, version);
+ const pricingContent = fs.readFileSync(pricingPath, 'utf-8');
+ const serviceService = container.resolve('serviceService');
+ await serviceService.addPricingToService(serviceName!, {path: pricingPath}, "file", organizationId!);
+
+ return returnContent ? pricingContent : pricingPath.split('/').pop()!.replace('.yaml', '');
+}
+
+async function deleteTestService(serviceName: string, organizationId: string): Promise {
+ const serviceService = container.resolve('serviceService');
+ await serviceService.disable(serviceName, organizationId);
+}
+
+async function getAllServices(organizationId: string, app?: any): Promise {
let appCopy = app;
if (!app) {
appCopy = getApp();
}
- const apiKey = await getTestAdminApiKey();
- const services = await request(appCopy).get(`${baseUrl}/services`).set('x-api-key', apiKey);
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
+ const services = await request(appCopy).get(`${baseUrl}/organizations/${organizationId}/services`).set('x-api-key', apiKey);
return services.body;
}
@@ -28,6 +99,7 @@ async function getAllServices(app?: any): Promise {
async function getPricingFromService(
serviceName: string,
pricingVersion: string,
+ organizationId: string,
app?: any
): Promise {
let appCopy = app;
@@ -36,9 +108,10 @@ async function getPricingFromService(
appCopy = getApp();
}
- const apiKey = await getTestAdminApiKey();
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
const pricing = await request(appCopy)
- .get(`${baseUrl}/services/${serviceName}/pricings/${pricingVersion}`)
+ .get(`${baseUrl}/organizations/${organizationId}/services/${serviceName}/pricings/${pricingVersion}`)
.set('x-api-key', apiKey);
return pricing.body;
@@ -74,16 +147,17 @@ async function getRandomService(app?: any): Promise {
return randomService;
}
-async function getService(serviceName: string, app?: any): Promise {
+async function getService(organizationId: string, serviceName: string, app?: any): Promise {
let appCopy = app;
if (!app) {
appCopy = await getApp();
}
- const apiKey = await getTestAdminApiKey();
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
const response = await request(appCopy)
- .get(`${baseUrl}/services/${serviceName}`)
+ .get(`${baseUrl}/organizations/${organizationId}/services/${serviceName}`)
.set('x-api-key', apiKey);
if (response.status !== 200) {
@@ -153,7 +227,7 @@ async function createService(testService?: string) {
}
}
-async function createRandomService(app?: any) {
+async function createRandomService(organizationId: string,app?: any) {
let appCopy = app;
if (!app) {
@@ -164,9 +238,10 @@ async function createRandomService(app?: any) {
uuidv4()
);
- const apiKey = await getTestAdminApiKey();
+ const adminUser: LeanUser = await createTestUser('ADMIN');
+ const apiKey = adminUser.apiKey;
const response = await request(appCopy)
- .post(`${baseUrl}/services`)
+ .post(`${baseUrl}/organizations/${organizationId}/services`)
.set('x-api-key', apiKey)
.attach('pricing', pricingFilePath);
@@ -179,6 +254,7 @@ async function createRandomService(app?: any) {
}
async function archivePricingFromService(
+ organizationId: string,
serviceName: string,
pricingVersion: string,
app?: any
@@ -187,7 +263,7 @@ async function archivePricingFromService(
const apiKey = await getTestAdminApiKey();
const response = await request(appCopy)
- .put(`${baseUrl}/services/${serviceName}/pricings/${pricingVersion}?availability=archived`)
+ .put(`${baseUrl}/organizations/${organizationId}/services/${serviceName}/pricings/${pricingVersion}?availability=archived`)
.set('x-api-key', apiKey)
.send({
subscriptionPlan: "BASIC"
@@ -204,6 +280,7 @@ async function archivePricingFromService(
}
async function deletePricingFromService(
+ organizationId: string,
serviceName: string,
pricingVersion: string,
app?: any
@@ -216,7 +293,7 @@ async function deletePricingFromService(
const apiKey = await getTestAdminApiKey();
const response = await request(appCopy)
- .delete(`${baseUrl}/services/${serviceName}/pricings/${pricingVersion}`)
+ .delete(`${baseUrl}/organizations/${organizationId}/services/${serviceName}/pricings/${pricingVersion}`)
.set('x-api-key', apiKey);
if (response.status !== 204 && response.status !== 404) {
@@ -225,13 +302,18 @@ async function deletePricingFromService(
}
export {
+ addPricingToService,
+ addArchivedPricingToService,
getAllServices,
getRandomPricingFile,
getService,
getPricingFromService,
getRandomService,
createService,
+ createTestService,
+ createMultipleTestServices,
createRandomService,
archivePricingFromService,
deletePricingFromService,
+ deleteTestService,
};
diff --git a/api/src/test/utils/testApp.ts b/api/src/test/utils/testApp.ts
index d6de0ae..cfd7be2 100644
--- a/api/src/test/utils/testApp.ts
+++ b/api/src/test/utils/testApp.ts
@@ -12,7 +12,7 @@ const baseUrl = process.env.BASE_URL_PATH ?? '/api/v1';
const getApp = async (): Promise => {
if (!testServer) {
- const { server, app } = await initializeServer();
+ const { server, app } = await initializeServer(false);
testServer = server;
testApp = app;
}
diff --git a/api/src/test/utils/users/userTestUtils.ts b/api/src/test/utils/users/userTestUtils.ts
index 5dc349f..1f51e24 100644
--- a/api/src/test/utils/users/userTestUtils.ts
+++ b/api/src/test/utils/users/userTestUtils.ts
@@ -2,12 +2,12 @@ import request from 'supertest';
import { baseUrl } from '../testApp';
import { Server } from 'http';
import UserMongoose from '../../../main/repositories/mongoose/models/UserMongoose';
-import { Role, USER_ROLES } from '../../../main/types/models/User';
+import { UserRole, USER_ROLES } from '../../../main/types/permissions';
// Create a test user directly in the database
-export const createTestUser = async (role: Role = USER_ROLES[USER_ROLES.length - 1]): Promise => {
+export const createTestUser = async (role: UserRole = USER_ROLES[USER_ROLES.length - 1], username: string = `test_user_${Date.now()}`): Promise => {
const userData = {
- username: `test_user_${Date.now()}`,
+ username: username,
password: 'password123',
role
};
@@ -29,7 +29,7 @@ export const regenerateApiKey = async (app: Server, userId: string, apiKey: stri
};
// Change the role of a user
-export const changeUserRole = async (app: Server, userId: string, newRole: Role, apiKey: string): Promise => {
+export const changeUserRole = async (app: Server, userId: string, newRole: UserRole, apiKey: string): Promise => {
const response = await request(app)
.put(`${baseUrl}/users/${userId}/role`)
.set('x-api-key', apiKey)
diff --git a/docker-compose.yml b/docker-compose.yml
index 632b9bb..7c33fa6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,12 +14,12 @@ services:
- './api/src/main/init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh'
networks:
- space-network
- # healthcheck:
- # test: [ "CMD", "mongo", "--username", "${MONGO_INITDB_ROOT_USERNAME:-root}", "--password", "${MONGO_INITDB_ROOT_PASSWORD:-4dm1n}", "--authenticationDatabase", "admin", "--eval", "db.adminCommand('ping')" ]
- # interval: 5s
- # timeout: 5s
- # retries: 3
- # start_period: 5s
+ healthcheck:
+ test: [ "CMD", "mongo", "--username", "${MONGO_INITDB_ROOT_USERNAME:-root}", "--password", "${MONGO_INITDB_ROOT_PASSWORD:-4dm1n}", "--authenticationDatabase", "admin", "--eval", "db.adminCommand('ping')" ]
+ interval: 5s
+ timeout: 5s
+ retries: 3
+ start_period: 5s
redis:
image: redis:7.4.2
container_name: space-redis
diff --git a/docs/domain-model.puml b/docs/domain-model.puml
new file mode 100644
index 0000000..de77a0b
--- /dev/null
+++ b/docs/domain-model.puml
@@ -0,0 +1,288 @@
+@startuml Domain Model
+
+' Styles
+skinparam classAttributeIconSize 0
+skinparam class {
+ BackgroundColor<> LightBlue
+ BackgroundColor<> LightYellow
+ BackgroundColor<> LightGreen
+}
+
+' Enumerations
+enum UserRole <> {
+ ADMIN
+ USER
+}
+
+enum OrganizationApiKeyRole <> {
+ ALL
+ MANAGEMENT
+ EVALUATION
+}
+
+enum OrganizationUserRole <> {
+ ADMIN
+ MANAGER
+ EVALUATOR
+}
+
+enum ValueType <> {
+ BOOLEAN
+ TEXT
+ NUMERIC
+}
+
+enum FeatureType <> {
+ INFORMATION
+ INTEGRATION
+ DOMAIN
+ AUTOMATION
+ MANAGEMENT
+ GUARANTEE
+ SUPPORT
+ PAYMENT
+}
+
+enum UsageLimitType <> {
+ RENEWABLE
+ NON_RENEWABLE
+}
+
+enum PeriodUnit <> {
+ SEC
+ MIN
+ HOUR
+ DAY
+ MONTH
+ YEAR
+}
+
+' Main Entities
+class User <> {
+ - username: String
+ - password: String
+ - role: UserRole
+ - createdAt: Date
+ - updatedAt: Date
+ --
+ + verifyPassword(password: String): Boolean
+}
+
+class Organization <>{
+ - id: String
+ - name: String
+ - owner: User
+ - apiKeys: OrganizationApiKey[]
+ - members: OrganizationUser[]
+ - createdAt: Date
+ - updatedAt: Date
+}
+
+class Service <> {
+ - name: String
+ - disabled: Boolean
+ - activePricings: Map
+ - archivedPricings: Map
+}
+
+class Pricing <