From e9748a74a2e9c901025183fd4deabd0aa0c5dd9a Mon Sep 17 00:00:00 2001 From: Inamul-hasan-tec Date: Tue, 8 Sep 2026 16:26:41 +0530 Subject: [PATCH] fix(pim): harden local integration and provisioning flows --- scripts/run-provisioning-test.mjs | 22 +++++++++++++++ src/features/apiKeys/apiKey.service.js | 2 +- .../integrations/integration.service.js | 9 +++--- .../integrations/routes/integration.routes.js | 28 +++++++++---------- .../org/saasProvisioning.controller.js | 7 +++-- .../products/products/completeness.service.js | 6 ++-- src/shared/database/connection.js | 6 ++-- 7 files changed, 53 insertions(+), 27 deletions(-) create mode 100644 scripts/run-provisioning-test.mjs diff --git a/scripts/run-provisioning-test.mjs b/scripts/run-provisioning-test.mjs new file mode 100644 index 0000000..cff5c88 --- /dev/null +++ b/scripts/run-provisioning-test.mjs @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { spawn } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const secret = process.env.SAAS_PIM_MODULE_SECRET; + +if (!secret) { + console.error('Set SAAS_PIM_MODULE_SECRET'); + process.exit(1); +} + +const child = spawn(process.execPath, [join(__dirname, 'test-saas-tenant-provisioning-e2e.mjs')], { + stdio: 'inherit', + env: { + ...process.env, + SAAS_PIM_MODULE_SECRET: secret + } +}); + +child.on('exit', (code) => process.exit(code ?? 0)); diff --git a/src/features/apiKeys/apiKey.service.js b/src/features/apiKeys/apiKey.service.js index 71ce10d..ff5ef26 100644 --- a/src/features/apiKeys/apiKey.service.js +++ b/src/features/apiKeys/apiKey.service.js @@ -59,7 +59,7 @@ export class ApiKeyService { const record = await models.ApiKey.create({ tenant_id: context.tenantId, name, key_prefix: `pim_live_${prefix}`, key_hash: digest(plaintext), scopes: [PRODUCT_READ_SCOPE], - expires_at: new Date(Date.now() + expiresInDays * 86_400_000), created_by: context.userId || null + expires_at: new Date(Date.now() + expiresInDays * 86_400_000), created_by: Number.isInteger(Number(context.userId)) ? Number(context.userId) : null }); return { ...serialize(record), apiKey: plaintext, shownOnce: true }; } diff --git a/src/features/integrations/integration.service.js b/src/features/integrations/integration.service.js index 796f5a0..f8ae1e4 100644 --- a/src/features/integrations/integration.service.js +++ b/src/features/integrations/integration.service.js @@ -1,3 +1,4 @@ +import { Op } from 'sequelize'; import repository from './integration.repository.js'; import { ApiError } from '../../utils/helpers/ApiError.utils.js'; import { AuditService } from '../../shared/services/audit.service.js'; @@ -83,8 +84,8 @@ async function resolveTenantChannel(channelReference, context) { if (!channelReference) return null; const channel = await models.Channel.findOne({ where: UUID_PATTERN.test(channelReference) - ? { id: channelReference, tenant_id: context.tenantId } - : { code: channelReference, tenant_id: context.tenantId } + ? { id: channelReference, [Op.or]: [{ tenant_id: context.tenantId }, { tenant_id: null }] } + : { code: channelReference, [Op.or]: [{ tenant_id: context.tenantId }, { tenant_id: null }] } }); if (!channel) throw new ApiError(400, 'Selected Channel does not belong to this tenant'); return channel; @@ -94,11 +95,11 @@ function normalizeGenericRestPayload(payload) { const endpoint = String(payload.endpoint || '').trim(); let url; try { url = new URL(endpoint); } catch { throw new ApiError(400, 'Product Delivery URL must be a complete HTTPS URL'); } - if (url.protocol !== 'https:') throw new ApiError(400, 'External Product Delivery URL must use HTTPS'); + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) throw new ApiError(400, 'External Product Delivery URL must use HTTPS (or http://localhost for local testing)'); if (payload.testEndpoint) { let testUrl; try { testUrl = new URL(payload.testEndpoint); } catch { throw new ApiError(400, 'Connection-test URL must be a complete HTTPS URL'); } - if (testUrl.protocol !== 'https:') throw new ApiError(400, 'External connection-test URL must use HTTPS'); + if (testUrl.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(testUrl.hostname)) throw new ApiError(400, 'External connection-test URL must use HTTPS (or http://localhost for local testing)'); } const method = String(payload.method || 'POST').toUpperCase(); if (!['POST', 'PUT', 'PATCH'].includes(method)) throw new ApiError(400, 'REST delivery method must be POST, PUT or PATCH'); diff --git a/src/features/integrations/routes/integration.routes.js b/src/features/integrations/routes/integration.routes.js index 1304b24..c879522 100644 --- a/src/features/integrations/routes/integration.routes.js +++ b/src/features/integrations/routes/integration.routes.js @@ -12,25 +12,25 @@ router.get( ); // ─── Protected routes ───────────────────────────────────────────────────────── -router.use(authenticate); +const guards = [authenticate, authorize(['settings.integrations'])]; -router.get('/integrations', authorize(['settings.integrations']), integrationController.listIntegrations); -router.post('/integrations', authorize(['settings.integrations']), integrationController.createIntegration); +router.get('/integrations', ...guards, integrationController.listIntegrations); +router.post('/integrations', ...guards, integrationController.createIntegration); -router.get('/integrations/:id', authorize(['settings.integrations']), integrationController.getIntegration); -router.put('/integrations/:id', authorize(['settings.integrations']), integrationController.updateIntegration); -router.delete('/integrations/:id', authorize(['settings.integrations']), integrationController.deleteIntegration); +router.get('/integrations/:id', ...guards, integrationController.getIntegration); +router.put('/integrations/:id', ...guards, integrationController.updateIntegration); +router.delete('/integrations/:id', ...guards, integrationController.deleteIntegration); -router.get('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.getCredentials); -router.post('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.setCredentials); -router.post('/integrations/:id/test-connection', authorize(['settings.integrations']), integrationController.testConnection); -router.post('/integrations/:id/sync', authorize(['settings.integrations']), integrationController.triggerSync); +router.get('/integrations/:id/credentials', ...guards, integrationController.getCredentials); +router.post('/integrations/:id/credentials', ...guards, integrationController.setCredentials); +router.post('/integrations/:id/test-connection', ...guards, integrationController.testConnection); +router.post('/integrations/:id/sync', ...guards, integrationController.triggerSync); // ─── Shopify OAuth — Start flow (protected; user-initiated) ────────────────── -router.post('/integrations/:id/shopify/oauth/start', authorize(['settings.integrations']), integrationController.startShopifyOAuth); +router.post('/integrations/:id/shopify/oauth/start', ...guards, integrationController.startShopifyOAuth); -router.get('/integrations/jobs/all', authorize(['settings.integrations']), integrationController.listAllSyncJobs); -router.get('/integrations/:id/jobs', authorize(['settings.integrations']), integrationController.listSyncJobs); -router.get('/integrations/jobs/:jobId/items', authorize(['settings.integrations']), integrationController.listSyncItems); +router.get('/integrations/jobs/all', ...guards, integrationController.listAllSyncJobs); +router.get('/integrations/:id/jobs', ...guards, integrationController.listSyncJobs); +router.get('/integrations/jobs/:jobId/items', ...guards, integrationController.listSyncItems); export default router; diff --git a/src/features/organization/org/saasProvisioning.controller.js b/src/features/organization/org/saasProvisioning.controller.js index ce3fd63..87f5697 100644 --- a/src/features/organization/org/saasProvisioning.controller.js +++ b/src/features/organization/org/saasProvisioning.controller.js @@ -2,8 +2,11 @@ import { handleSaaSEvent } from "./saasProvisioning.service.js"; export async function provisionTenant(req, res, next) { try { - const payload = req.body || {}; - const explicitEventId = req.get("x-integration-event-id") || req.get("x-event-id") || payload.event_id || payload.provisioning_id; + const body = req.body || {}; + // SaaS outbox sends a standard event envelope. Direct callers may still + // send the provisioning payload itself, so accept both contracts. + const payload = body.data || body.payload || body; + const explicitEventId = req.get("x-integration-event-id") || req.get("x-event-id") || body.event_id || payload.event_id || payload.provisioning_id; const result = await handleSaaSEvent("TENANT_PROVISION_REQUESTED", payload, { eventId: explicitEventId }); if (result.duplicate) { diff --git a/src/features/products/products/completeness.service.js b/src/features/products/products/completeness.service.js index e32c84f..ed997fc 100644 --- a/src/features/products/products/completeness.service.js +++ b/src/features/products/products/completeness.service.js @@ -246,7 +246,7 @@ export class CompletenessService { if (variant.sku && variant.sku.trim() !== '') { variantsFulfilledChecks++; } else { - missingGeneral.push({ code: `variant_${variant.id}_sku`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" SKU` }); + missingGeneral.push({ code: `variant_${variant.id}_sku`, name: `Variant "${((variant.name && typeof variant.name === 'string') ? (variant.name.split(' - ')[1] || variant.name) : (variant.sku || 'Variant'))}" SKU` }); } // Check Price @@ -254,7 +254,7 @@ export class CompletenessService { if (variant.price !== undefined && variant.price !== null && Number(variant.price) > 0) { variantsFulfilledChecks++; } else { - missingGeneral.push({ code: `variant_${variant.id}_price`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" Price` }); + missingGeneral.push({ code: `variant_${variant.id}_price`, name: `Variant "${((variant.name && typeof variant.name === 'string') ? (variant.name.split(' - ')[1] || variant.name) : (variant.sku || 'Variant'))}" Price` }); } // Check assets: if there are any variant-eligible asset types, they must be assigned to this variant! @@ -271,7 +271,7 @@ export class CompletenessService { if (hasAsset) { variantsFulfilledChecks++; } else { - missingGeneral.push({ code: `variant_${variant.id}_asset_${at.code}`, name: `Variant "${variant.name.split(' - ')[1] || variant.name}" ${at.name}` }); + missingGeneral.push({ code: `variant_${variant.id}_asset_${at.code}`, name: `Variant "${((variant.name && typeof variant.name === 'string') ? (variant.name.split(' - ')[1] || variant.name) : (variant.sku || 'Variant'))}" ${at.name}` }); } } } diff --git a/src/shared/database/connection.js b/src/shared/database/connection.js index fa3ecd8..e7769c1 100644 --- a/src/shared/database/connection.js +++ b/src/shared/database/connection.js @@ -142,9 +142,9 @@ export const connectDatabase = async () => { } } catch (error) { console.error('Unable to connect to the database:', error); - if (env === 'production') { - throw error; - } + // A web server without a usable schema is never healthy. Fail startup in + // every environment instead of exposing routes that can only return 500. + throw error; } };