Files
productcatalogue_backend/scripts/test-tenant-api-keys-step-by-step.mjs
T

93 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const base = process.env.PIM_TEST_API_URL || 'http://127.0.0.1:5003/api/v1';
const email = process.env.PIM_TEST_EMAIL;
const password = process.env.PIM_TEST_PASSWORD;
const platformEmail = process.env.PIM_PLATFORM_EMAIL;
const platformPassword = process.env.PIM_PLATFORM_PASSWORD;
if (!email || !password) throw new Error('Set PIM_TEST_EMAIL and PIM_TEST_PASSWORD first');
const prompt = createInterface({ input, output });
let token;
let apiKey;
let apiKeyId;
async function pause(title, explanation) {
console.log(`\n============================================================\n${title}\n${explanation}\n============================================================`);
await prompt.question('Press Enter to run only this step...');
}
async function call(path, options = {}) {
const response = await fetch(`${base}${path}`, options);
let body = null;
try { body = await response.json(); } catch { body = null; }
console.log(`${options.method || 'GET'} ${path} -> ${response.status}`);
return { response, body };
}
function requireResult(condition, message) {
if (!condition) throw new Error(message);
console.log(`✓ ${message}`);
}
try {
await pause('STEP 1 — Tenant login', 'This proves a human tenant administrator is allowed to manage keys. The API key does not exist yet.');
const login = await call('/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) });
token = login.body?.data?.accessToken || login.body?.accessToken || login.body?.token;
requireResult(login.response.ok && token, 'Tenant administrator login succeeded');
await pause('STEP 2 — Create one read-only key', 'The complete secret will appear once. The database stores only its prefix and one-way keyed hash.');
const created = await call('/api-keys', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ name: `Guided test ${Date.now()}`, expiresInDays: 1 }) });
apiKey = created.body?.data?.apiKey; apiKeyId = created.body?.data?.id;
requireResult(created.response.status === 201 && apiKey, 'Key was created with products:read only');
console.log(`One-time key for this guided test:\n${apiKey}`);
await pause('STEP 3 — List keys safely', 'The list must show only the public prefix. It must not return the complete secret shown above.');
const list = await call('/api-keys', { headers: { authorization: `Bearer ${token}` } });
const listed = list.body?.data?.find(item => item.id === apiKeyId);
requireResult(listed && !JSON.stringify(listed).includes(apiKey), 'Key is listed without exposing its secret');
console.log({ name: listed.name, prefix: listed.prefix, scope: listed.scopes, status: listed.status });
await pause('STEP 4 — Read this tenants product collection', 'The server derives tenantId from the verified key. No tenant header or tenant query parameter is accepted.');
const products = await call('/external/products', { headers: { 'x-api-key': apiKey } });
requireResult(products.response.ok && Array.isArray(products.body?.data), 'Tenant product collection returned');
console.log(`Products visible to this key: ${products.body.data.length}`);
const ownProduct = products.body.data[0];
requireResult(Boolean(ownProduct?.id), 'A tenant product is available for the next step');
await pause('STEP 5 — Read one owned product', 'The same tenant context is applied when looking up a specific product UUID.');
const owned = await call(`/external/products/${ownProduct.id}`, { headers: { authorization: `Bearer ${apiKey}` } });
requireResult(owned.response.ok && owned.body?.data?.id === ownProduct.id, 'Owned product returned through Bearer API-key authentication');
if (platformEmail && platformPassword) {
await pause('STEP 6 — Direct cross-tenant attack test', 'The script finds a product owned by another tenant, then requests its UUID with this tenant key. The correct result is 404, not 403, so existence is hidden.');
const platformLogin = await call('/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: platformEmail, password: platformPassword }) });
const platformToken = platformLogin.body?.data?.accessToken || platformLogin.body?.accessToken || platformLogin.body?.token;
const tenantPayload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
const tenants = await call('/platform/tenants', { headers: { authorization: `Bearer ${platformToken}` } });
const rows = tenants.body?.data?.rows || tenants.body?.data || [];
const otherTenant = rows.find(item => String(item.id) !== String(tenantPayload.tenant_id));
requireResult(Boolean(otherTenant), 'A second tenant is available');
const otherProducts = await call('/products', { headers: { authorization: `Bearer ${platformToken}`, 'x-impersonated-tenant-id': String(otherTenant.id) } });
const otherProduct = otherProducts.body?.data?.[0];
requireResult(Boolean(otherProduct?.id), 'A product belonging to the second tenant is available');
const denied = await call(`/external/products/${otherProduct.id}`, { headers: { 'x-api-key': apiKey } });
requireResult(denied.response.status === 404, 'Cross-tenant product access was hidden with 404');
} else {
console.log('\nSTEP 6 skipped: add PIM_PLATFORM_EMAIL and PIM_PLATFORM_PASSWORD to run the direct two-tenant proof.');
}
await pause('FINAL STEP — Revoke the temporary key', 'After revocation, the exact same secret must immediately return 401.');
const revoked = await call(`/api-keys/${apiKeyId}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` } });
requireResult(revoked.response.ok, 'Temporary key revoked');
const deniedAfterRevoke = await call('/external/products', { headers: { 'x-api-key': apiKey } });
requireResult(deniedAfterRevoke.response.status === 401, 'Revoked key was immediately rejected');
console.log('\nGUIDED TEST COMPLETE — no active test key was left behind.');
} finally {
if (apiKeyId && token) {
await fetch(`${base}/api-keys/${apiKeyId}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` } }).catch(() => {});
}
prompt.close();
}