93 lines
6.3 KiB
JavaScript
93 lines
6.3 KiB
JavaScript
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 tenant’s 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();
|
||
}
|