fix(pim): harden local integration and provisioning flows
This commit is contained in:
@@ -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));
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user