diff --git a/api/docs/space-api-docs.yaml b/api/docs/space-api-docs.yaml index 0478c54..778d055 100644 --- a/api/docs/space-api-docs.yaml +++ b/api/docs/space-api-docs.yaml @@ -2319,6 +2319,13 @@ paths: 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 @@ -2446,6 +2453,47 @@ paths: '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: | @@ -2637,6 +2685,13 @@ paths: 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 @@ -2774,6 +2829,47 @@ paths: application/json: schema: $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: + type: array + items: + $ref: '#/components/schemas/Contract' delete: summary: Delete all contracts @@ -2796,6 +2892,43 @@ paths: '204': 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: + type: array + items: + $ref: '#/components/schemas/Contract' + /contracts/{userId}: get: summary: Get contract diff --git a/api/src/main/config/permissions.ts b/api/src/main/config/permissions.ts index 5a797dd..bc04643 100644 --- a/api/src/main/config/permissions.ts +++ b/api/src/main/config/permissions.ts @@ -174,13 +174,7 @@ export const ROUTE_PERMISSIONS: RoutePermission[] = [ // ============================================ { path: '/contracts', - methods: ['GET'], - allowedUserRoles: ['ADMIN'], - allowedOrgRoles: ['ALL', 'MANAGEMENT'], - }, - { - path: '/contracts', - methods: ['POST'], + methods: ['GET', 'POST', 'PUT'], allowedUserRoles: ['ADMIN'], allowedOrgRoles: ['ALL', 'MANAGEMENT'], }, diff --git a/api/src/main/controllers/ContractController.ts b/api/src/main/controllers/ContractController.ts index 9993368..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); @@ -70,7 +72,23 @@ class ContractController { const contractData: ContractToCreate = req.body; const authOrganizationId = req.org?.id ?? req.params.organizationId; - contractData.organizationId = authOrganizationId; + 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); @@ -82,7 +100,7 @@ class ContractController { } 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 }); } } } @@ -104,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; @@ -123,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 }); } @@ -158,7 +231,6 @@ class ContractController { async prune(req: any, res: any) { try { - const organizationId = req.org?.id ?? req.params.organizationId; const result: number = await this.contractService.prune(organizationId, req.user); @@ -196,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/repositories/mongoose/ContractRepository.ts b/api/src/main/repositories/mongoose/ContractRepository.ts index 3d5107c..ac5ad13 100644 --- a/api/src/main/repositories/mongoose/ContractRepository.ts +++ b/api/src/main/repositories/mongoose/ContractRepository.ts @@ -26,6 +26,7 @@ const buildMatchPipeline = (queryFilters: any): ContractMatchPipeline => { order = 'asc', filters, organizationId, + groupId, } = queryFilters || {}; const matchConditions: any[] = []; @@ -45,6 +46,9 @@ const buildMatchPipeline = (queryFilters: any): ContractMatchPipeline => { if (organizationId) { matchConditions.push({ organizationId: organizationId }); } + if (groupId) { + matchConditions.push({ groupId: groupId }); + } const pipeline: any[] = [ { $addFields: { contractedServicesArray: { $objectToArray: '$contractedServices' } } }, diff --git a/api/src/main/repositories/mongoose/models/ContractMongoose.ts b/api/src/main/repositories/mongoose/models/ContractMongoose.ts index dcd67ab..b205b72 100644 --- a/api/src/main/repositories/mongoose/models/ContractMongoose.ts +++ b/api/src/main/repositories/mongoose/models/ContractMongoose.ts @@ -26,6 +26,7 @@ const contractSchema = new Schema( }, 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/routes/ContractRoutes.ts b/api/src/main/routes/ContractRoutes.ts index 419b24e..8a68bb3 100644 --- a/api/src/main/routes/ContractRoutes.ts +++ b/api/src/main/routes/ContractRoutes.ts @@ -12,39 +12,87 @@ const loadFileRoutes = function (app: express.Application) { 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) + .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 + + 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) + .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 + + 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/services/ContractService.ts b/api/src/main/services/ContractService.ts index 8b674c4..5fff6f7 100644 --- a/api/src/main/services/ContractService.ts +++ b/api/src/main/services/ContractService.ts @@ -221,6 +221,36 @@ class ContractService { 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}`); @@ -328,6 +358,47 @@ class ContractService { 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, 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/types/models/Contract.ts b/api/src/main/types/models/Contract.ts index 95a8b82..dd44a2c 100644 --- a/api/src/main/types/models/Contract.ts +++ b/api/src/main/types/models/Contract.ts @@ -29,6 +29,7 @@ export interface LeanContract { }; usageLevels: Record>; organizationId: string; + groupId?: string; contractedServices: Record; subscriptionPlans: Record; subscriptionAddOns: Record>; @@ -41,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; @@ -66,6 +69,7 @@ export interface ContractToCreate { 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/test/contract.test.ts b/api/src/test/contract.test.ts index 4f0557a..92526b2 100644 --- a/api/src/test/contract.test.ts +++ b/api/src/test/contract.test.ts @@ -97,12 +97,63 @@ describe('Contract API routes', function () { expect(response.body[0].userContact.username).toBe(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?groupId=${sharedGroupId}`) + .set('x-api-key', testOrgApiKey); + + 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.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('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`) + .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); + }); }); describe('GET /organizations/:organizationId/contracts', function () { @@ -137,6 +188,43 @@ describe('Contract API routes', function () { 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}/organizations/${foreignOrganization.id}/contracts`) + .set('x-api-key', ownerUser.apiKey); + + 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.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); + }); }); describe('POST /contracts', function () { @@ -321,6 +409,223 @@ describe('Contract API routes', function () { expect(response.status).toBe(401); expect(response.body.error).toContain('API Key'); }); + + 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 contractData = await generateContract( + { [foreignService.name.toLowerCase()]: foreignService.activePricings.keys().next().value! }, + foreignOrganization.id!, + undefined, + app + ); + + 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('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) + .post(`${baseUrl}/organizations/${foreignOrganization.id}/contracts`) + .set('x-api-key', adminUser.apiKey) + .send(contractData); + + 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()}`; + + 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 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 + ); + + const novationData = { + contractedServices: { [novationService.name.toLowerCase()]: pricingVersion }, + subscriptionPlans: { [novationService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] }, + subscriptionAddOns: {}, + }; + + const response = await request(app) + .put(`${baseUrl}/organizations/${testOrganization.id}/contracts?groupId=${sharedGroupId}`) + .set('x-api-key', ownerUser.apiKey) + .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}/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('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) + .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.status).toBe(403); + + await deleteTestService(foreignService.name, foreignOrganization.id!); + await deleteTestOrganization(foreignOrganization.id!); + await deleteTestUser(foreignOrgOwner.username); + }); + + 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 + ); + + 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 () { @@ -494,6 +799,429 @@ describe('Contract API routes', function () { expect(response.status).toBe(401); expect(response.body.error).toContain('API Key'); }); + + 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) + .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(403); + + await deleteTestService(foreignService.name, foreignOrganization.id!); + await deleteTestOrganization(foreignOrganization.id!); + await deleteTestUser(foreignOrgOwner.username); + }); + + 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 novationData = { + contractedServices: { [newService.name.toLowerCase()]: pricingVersion }, + subscriptionPlans: { [newService.name.toLowerCase()]: Object.keys(pricingDetails!.plans!)[0] }, + subscriptionAddOns: {}, + }; + + const response = await request(app) + .put(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`) + .set('x-api-key', adminUser.apiKey) + .send(novationData); + + 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); + }); + }); + + 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 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) + .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.error).toContain('Missing groupId'); + }); + + 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: {}, + }); + + expect(response.status).toBe(404); + expect(response.body.error.toLowerCase()).toContain('no contracts found'); + }); + + 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: {}, + }); + + 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(response.status).toBe(422); + }); + + 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) + .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: {}, + }); + + 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); + + await deleteTestService(adminService.name, testOrganization.id!); + }); + + it('returns 401 when API key is missing', async function () { + const response = await request(app) + .put(`${baseUrl}/contracts?groupId=any-group`) + .send({ + contractedServices: { [testService.name.toLowerCase()]: testService.activePricings.keys().next().value! }, + subscriptionPlans: {}, + subscriptionAddOns: {}, + }); + + expect(response.status).toBe(401); + expect(response.body.error).toContain('API Key'); + }); + }); + + 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 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/billingPeriod?groupId=${sharedGroupId}`) + .set('x-api-key', testOrgApiKey) + .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 === 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(foreignContractResponse.body.billingPeriod.renewalDays).toBe( + foreignContractBefore.body.billingPeriod.renewalDays + ); + + 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/billingPeriod`) + .set('x-api-key', testOrgApiKey) + .send({ autoRenew: true, renewalDays: 30 }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('Missing groupId'); + }); + + it('returns 404 when no contracts are found for groupId in the requesting organization', async function () { + const response = await request(app) + .put(`${baseUrl}/contracts/billingPeriod?groupId=group-does-not-exist`) + .set('x-api-key', testOrgApiKey) + .send({ autoRenew: true, renewalDays: 30 }); + + expect(response.status).toBe(404); + expect(response.body.error.toLowerCase()).toContain('not found'); + }); + + 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(response.status).toBe(403); + }); + + it('returns 422 when payload is invalid', async function () { + const response = await request(app) + .put(`${baseUrl}/contracts/billingPeriod?groupId=any-group`) + .set('x-api-key', testOrgApiKey) + .send({ autoRenew: 'invalid-boolean' }); + + 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 billingData = { + autoRenew: !adminBillingContract.billingPeriod.autoRenew, + renewalDays: adminBillingContract.billingPeriod.renewalDays === 30 ? 365 : 30, + }; + + 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('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 }); + + expect(response.status).toBe(401); + expect(response.body.error).toContain('API Key'); + }); + + 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 billingData = { + autoRenew: !adminBillingContract.billingPeriod.autoRenew, + renewalDays: adminBillingContract.billingPeriod.renewalDays === 30 ? 365 : 30, + }; + + 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); + }); }); describe('DELETE /contracts/:userId', function () { @@ -556,6 +1284,46 @@ describe('Contract API routes', function () { expect(response.status).toBe(401); expect(response.body.error).toContain('API Key'); }); + + 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); + + const response = await request(app) + .delete(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`) + .set('x-api-key', ownerUser.apiKey); + + expect(response.status).toBe(403); + + await deleteTestService(foreignService.name, foreignOrganization.id!); + await deleteTestOrganization(foreignOrganization.id!); + await deleteTestUser(foreignOrgOwner.username); + }); + + 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) + .delete(`${baseUrl}/organizations/${foreignOrganization.id}/contracts/${foreignContract.userContact.userId}`) + .set('x-api-key', adminUser.apiKey); + + expect(response.status).toBe(204); + + 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); + + await deleteTestService(foreignService.name, foreignOrganization.id!); + await deleteTestOrganization(foreignOrganization.id!); + await deleteTestUser(foreignOrgOwner.username); + }); }); describe('DELETE /contracts', function () { diff --git a/api/src/test/permissions.test.ts b/api/src/test/permissions.test.ts index 1f322e0..cefcf7e 100644 --- a/api/src/test/permissions.test.ts +++ b/api/src/test/permissions.test.ts @@ -1904,13 +1904,18 @@ describe('Permissions Test Suite', function () { const testOrg = await createTestOrganization(ownerUser.username); const testService = await createTestService(testOrg.id!) - const contractData = await generateContract( + 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) diff --git a/api/src/test/utils/contracts/contractTestUtils.ts b/api/src/test/utils/contracts/contractTestUtils.ts index c156dd2..97beb81 100644 --- a/api/src/test/utils/contracts/contractTestUtils.ts +++ b/api/src/test/utils/contracts/contractTestUtils.ts @@ -10,7 +10,7 @@ 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): Promise { +async function createTestContract(organizationId: string, services: LeanService[], app: any, groupId?: string): Promise { if (!app){ app = await getApp(); } @@ -27,7 +27,7 @@ async function createTestContract(organizationId: string, services: LeanService[ {} as Record ); - const contractData: ContractToCreate = await generateContract(contractedServices, organizationId, undefined, app); + const contractData: ContractToCreate = await generateContract(contractedServices, organizationId, undefined, app, groupId); const adminUser: LeanUser = await createTestUser('ADMIN'); const apiKey = adminUser.apiKey; diff --git a/api/src/test/utils/contracts/generators.ts b/api/src/test/utils/contracts/generators.ts index fa4e71c..7b238d6 100644 --- a/api/src/test/utils/contracts/generators.ts +++ b/api/src/test/utils/contracts/generators.ts @@ -24,7 +24,8 @@ async function generateContract( contractedServices: Record, organizationId: string, userId?: string, - app?: any + app?: any, + groupId?: string ): Promise { const appCopy = await useApp(app); @@ -64,6 +65,7 @@ async function generateContract( renewalDays: faker.helpers.arrayElement([30, 365]), }, organizationId: organizationId, + groupId: groupId, contractedServices: contractedServices, subscriptionPlans: subscriptionPlans, subscriptionAddOns: subscriptionAddOns, diff --git a/docs/domain-model.puml b/docs/domain-model.puml index ab62bd2..de77a0b 100644 --- a/docs/domain-model.puml +++ b/docs/domain-model.puml @@ -98,6 +98,7 @@ class Pricing <> { class Contract <> { - userContact: UserContact - billingPeriod: BillingPeriod + - groupId: String - usageLevels: Map> - contractedServices: Map - subscriptionPlans: Map