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

93 lines
6.3 KiB
JavaScript
Raw Normal View History

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