fix(pim): harden local integration and provisioning flows

This commit is contained in:
Inamul-hasan-tec
2026-09-08 16:26:41 +05:30
parent 53a0238a23
commit e9748a74a2
7 changed files with 53 additions and 27 deletions
+22
View File
@@ -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));
+1 -1
View File
@@ -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 };
}
@@ -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');
@@ -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;
@@ -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) {
@@ -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}` });
}
}
}
+3 -3
View File
@@ -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;
}
};