Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions api/docs/space-api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ info:
contact:
email: agarcia29@us.es
version: 1.0.0
license:
name: MIT License
url: https://opensource.org/license/mit
externalDocs:
description: Find out more about Pricing-driven Solutions
url: https://sphere.score.us.es/
Expand Down Expand Up @@ -2243,7 +2246,7 @@ components:
extends it by 10, then evaluations will consider 20 as the value
of the usage limit'
example: 1000
subscriptionContraint:
subscriptionConstraints:
type: object
description: >-
Defines some restrictions that must be taken into consideration
Expand Down Expand Up @@ -2434,7 +2437,7 @@ components:
description: >-
Indicates how many times has the add-on been contracted within the
subscription. This number must be within the range defined by the
`subscriptionContraint` of the add-on
`subscriptionConstraints` of the add-on
example: 1
minimum: 0
example:
Expand Down
10 changes: 9 additions & 1 deletion api/src/main/controllers/validation/ContractValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const create = [
.exists({ checkNull: true })
.withMessage('The userContact.userId field is required')
.isString()
.withMessage('The userContact.userId field must be a string'),
.withMessage('The userContact.userId field must be a string')
.notEmpty()
.withMessage('The userContact.userId field cannot be empty'),
check('userContact.username')
.exists({ checkNull: true })
.withMessage('The userContact.username field is required')
Expand Down Expand Up @@ -315,6 +317,11 @@ function _validateAddOns(
}

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}`);
}

_validateAddOnAvailability(addOnName, selectedPlan, pricing);
_validateDependentAddOns(addOnName, selectedAddOns, pricing);
_validateExcludedAddOns(addOnName, selectedAddOns, pricing);
Expand Down Expand Up @@ -342,6 +349,7 @@ function _validateDependentAddOns(
selectedAddOns: Record<string, number>,
pricing: LeanPricing
): void {

const dependentAddOns = pricing.addOns![addOnName].dependsOn ?? [];
if (!dependentAddOns.every(dependentAddOn => selectedAddOns.hasOwnProperty(dependentAddOn))) {
throw new Error(
Expand Down
4 changes: 3 additions & 1 deletion api/src/main/middlewares/ValidationHandlingMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ const handleValidation = async (req: any, res: any, next: NextFunction) => {
const err = validationResult(req) as Result<ValidationError>;

if (err.array().length > 0) {
res.status(422).send({errors: err.array()});
const first = err.array()[0];
const message = first && (first.msg as unknown as string) ? (first.msg as unknown as string) : 'Invalid request';
res.status(422).send({ error: message });
} else {
next();
}
Expand Down
16 changes: 15 additions & 1 deletion api/src/main/services/ContractService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,21 @@ class ContractService {
}

async create(contractData: ContractToCreate): Promise<LeanContract> {

// Prevent creating more than one contract per userId
if (!contractData || !contractData.userContact || !contractData.userContact.userId) {
throw new Error('Invalid request: Missing userContact.userId');
}

const existingContract = await this.contractRepository.findByUserId(
contractData.userContact.userId
);

if (existingContract) {
throw new Error(
`Invalid request: 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);

Expand Down
23 changes: 16 additions & 7 deletions api/src/main/services/ServiceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,8 @@ class ServiceService {
if (validationErrors.length > 0) {
throw new Error(`Validation errors: ${validationErrors.join(', ')}`);
}

// Step 2: Save the pricing data to the database
const savedPricing = await this.pricingRepository.create(pricingData);
if (!savedPricing) {
throw new Error(`Pricing ${uploadedPricing.version} not saved`);
}
// Step 3:

// Step 2:
// - If the service does not exist (enabled), creates it
// - If an enabled service exists, updates it with the new pricing
// - If a disabled service exists with the same name, re-enable it, make the
Expand All @@ -268,6 +263,13 @@ class ServiceService {
throw new Error(`Invalid request: Service ${uploadedPricing.saasName} already exists`);
}

// 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`);
}

if (existingDisabled) {
// Re-enable flow: archive existing active pricings and archived pricings
const newArchived: Record<string, any> = { ...(existingDisabled.archivedPricings || {}) };
Expand Down Expand Up @@ -348,6 +350,13 @@ class ServiceService {
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`);
}

// If the service is disabled, re-enable it and move previous active/archived to archived
if ((service as any).disabled) {
const newArchived: Record<string, any> = { ...(service.archivedPricings || {}) };
Expand Down
77 changes: 77 additions & 0 deletions api/src/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,24 @@ describe('Contract API Test Suite', function () {
);
});

it('Should return 422 when userContact.userId is an empty string', async function () {
const {contract: contractToCreate} = await generateContractAndService(undefined, app);

// Force empty userId
contractToCreate.userContact.userId = '';

const response = await request(app)
.post(`${baseUrl}/contracts`)
.set('x-api-key', adminApiKey)
.send(contractToCreate);

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');
});

it('Should return 400 given a contract with unexistent service', async function () {
const {contract: contractToCreate} = await generateContractAndService(undefined, app);

Expand Down Expand Up @@ -293,6 +311,65 @@ describe('Contract API Test Suite', function () {
expect(response.body).toBeDefined();
expect(response.body.error).toBe(`Invalid contract: Pricing version invalid-version for service ${existingService} not found`);
});

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);

const serviceName = Object.keys(contractToCreate.contractedServices)[0];
// Set an invalid plan name
contractToCreate.subscriptionPlans[serviceName] = 'NON_EXISTENT_PLAN';

const response = await request(app)
.post(`${baseUrl}/contracts`)
.set('x-api-key', adminApiKey)
.send(contractToCreate);

expect(response.status).toBe(400);
expect(response.body).toBeDefined();
expect(response.body.error).toBeDefined();
expect(String(response.body.error)).toContain('Invalid subscription');
});

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);

const serviceName = Object.keys(contractToCreate.contractedServices)[0];
// Inject an invalid add-on name
contractToCreate.subscriptionAddOns[serviceName] = { 'non_existent_addon': 1 };

const response = await request(app)
.post(`${baseUrl}/contracts`)
.set('x-api-key', adminApiKey)
.send(contractToCreate);

expect(response.status).toBe(400);
expect(response.body).toBeDefined();
expect(response.body.error).toBeDefined();
expect(String(response.body.error)).toContain('Invalid subscription');
});

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);

const firstResponse = await request(app)
.post(`${baseUrl}/contracts`)
.set('x-api-key', adminApiKey)
.send(contractToCreate);

expect(firstResponse.status).toBe(201);

// 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(secondResponse.status).toBe(400);
expect(secondResponse.body).toBeDefined();
expect(secondResponse.body.error).toBeDefined();
expect(secondResponse.body.error.toLowerCase()).toContain('already exists');
});
});

describe('GET /contracts/:userId', function () {
Expand Down
28 changes: 28 additions & 0 deletions api/src/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,34 @@ describe('Services API Test Suite', function () {
expect((Object.values(response.body.activePricings)[0] as any).url).toBeDefined();
expect(response.body.archivedPricings).toBeUndefined();
});

it('Should return 4XX when creating a service with the same name as an existing one', async function () {
// create initial service
const pricingFilePath = await getRandomPricingFile(new Date().getTime().toString());
const first = await request(app)
.post(`${baseUrl}/services`)
.set('x-api-key', adminApiKey)
.attach('pricing', pricingFilePath);

expect(first.status).toEqual(201);

// 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)
.attach('pricing', pricingFilePath);

// It must be a 4xx error (client error), not 5xx
expect(second.status).toBeGreaterThanOrEqual(400);
expect(second.status).toBeLessThan(500);

expect(second.body).toBeDefined();
expect(second.body.error).toBeDefined();
const errMsg = String(second.body.error).toLowerCase();
expect(errMsg.length).toBeGreaterThan(0);
// Error message should mention existence/duplication
expect(['exists', 'already', 'duplicate'].some(k => errMsg.includes(k))).toBeTruthy();
});
});

describe('GET /services/{serviceName}', function () {
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ services:
dockerfile: ./docker/Dockerfile
args:
VITE_ENVIRONMENT: production
VITE_SPACE_BASE_URL: http://localhost/api/v1
VITE_SPACE_BASE_URL: http://localhost:5403/api/v1
depends_on:
- space-server
networks:
Expand Down
Loading