#!/usr/bin/env node
/**
* Shared test helpers for MCP-Strapi integration tests.
*
* Provides:
* - Strapi admin login & user management
* - MCP session management (Streamable HTTP)
* - Test assertion helpers with pass/fail/skip tracking
* - Role-based user factories (Admin, Editor, Author, Reader)
*
* For JWT auth flow testing, see jwt-provider.js (local JWKS + RS256 signing).
*/
import('dotenv').then((m) => m.config());
// ─── Configuration ───────────────────────────────────────────────────────────
export const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
export const MCP_ENDPOINT = `${STRAPI_URL}/api/mcp-server/mcp`;
export const ADMIN_EMAIL = process.env.STRAPI_ADMIN_EMAIL || 'admin@example.com';
export const ADMIN_PASSWORD = process.env.STRAPI_ADMIN_PASSWORD || 'Admin1234!';
// Additional test users (created by tests if they don't exist)
export const TEST_USERS = {
admin: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, role: 'Super Admin' },
editor: { email: 'editor@test.local', password: 'Editor1234!', firstname: 'Test', lastname: 'Editor' },
author: { email: 'author@test.local', password: 'Author1234!', firstname: 'Test', lastname: 'Author' },
reader: { email: 'reader@test.local', password: 'Reader1234!', firstname: 'Test', lastname: 'Reader' },
};
// ─── Test Result Tracking ────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
let skipped = 0;
const failures = [];
export function pass(testName) {
passed++;
console.log(` \x1b[32m✓\x1b[0m ${testName}`);
}
export function fail(testName, error) {
failed++;
const msg = error instanceof Error ? error.message : String(error);
failures.push({ test: testName, error: msg });
console.log(` \x1b[31m✗\x1b[0m ${testName}`);
console.log(` \x1b[2m${msg}\x1b[0m`);
}
export function skip(testName, reason) {
skipped++;
console.log(` \x1b[33m-\x1b[0m ${testName} \x1b[2m(${reason})\x1b[0m`);
}
export function section(name) {
console.log(`\n\x1b[1m── ${name} ──\x1b[0m`);
}
export function summary() {
console.log('\n\x1b[1m═══ Summary ═══\x1b[0m');
console.log(` Passed: \x1b[32m${passed}\x1b[0m`);
console.log(` Failed: \x1b[31m${failed}\x1b[0m`);
console.log(` Skipped: \x1b[33m${skipped}\x1b[0m`);
console.log(` Total: ${passed + failed + skipped}`);
if (failures.length > 0) {
console.log('\n\x1b[31mFailures:\x1b[0m');
for (const f of failures) {
console.log(` • ${f.test}: ${f.error}`);
}
}
return { passed, failed, skipped, failures };
}
export function resetCounters() {
passed = 0;
failed = 0;
skipped = 0;
failures.length = 0;
}
// ─── Assertion Helpers ───────────────────────────────────────────────────────
export function assert(condition, testName, errorDetail) {
if (condition) {
pass(testName);
} else {
fail(testName, errorDetail || 'Assertion failed');
}
return condition;
}
export function assertEqual(actual, expected, testName) {
if (actual === expected) {
pass(testName);
return true;
}
fail(testName, `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
return false;
}
export function assertIncludes(arr, value, testName) {
if (Array.isArray(arr) && arr.includes(value)) {
pass(testName);
return true;
}
fail(testName, `Expected array to include ${JSON.stringify(value)}`);
return false;
}
export function assertStatus(actual, expected, testName) {
if (actual === expected) {
pass(testName);
return true;
}
fail(testName, `Expected HTTP ${expected}, got HTTP ${actual}`);
return false;
}
// ─── HTTP Helpers ────────────────────────────────────────────────────────────
export async function fetchJSON(url, options = {}) {
const resp = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
const data = await resp.json().catch(() => null);
return { status: resp.status, data, headers: resp.headers };
}
// ─── Strapi Admin Auth ───────────────────────────────────────────────────────
/**
* Login to Strapi admin panel.
* Returns { token, user } or null on failure.
*/
export async function adminLogin(email, password) {
const { status, data } = await fetchJSON(`${STRAPI_URL}/admin/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (status === 200 && data?.data?.token) {
return { token: data.data.token, user: data.data.user };
}
return null;
}
/**
* Get the admin token for the primary super admin.
*/
export async function getSuperAdminToken() {
const result = await adminLogin(ADMIN_EMAIL, ADMIN_PASSWORD);
return result?.token || null;
}
/**
* List admin roles. Returns array of { id, name, code }.
*/
export async function listAdminRoles(adminToken) {
const { status, data } = await fetchJSON(`${STRAPI_URL}/admin/roles`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
if (status === 200 && data?.data) {
return data.data;
}
return [];
}
/**
* Find role by partial name match (case-insensitive).
*/
export async function findRoleByName(adminToken, partialName) {
const roles = await listAdminRoles(adminToken);
return roles.find((r) => r.name.toLowerCase().includes(partialName.toLowerCase()));
}
/**
* Create a Strapi admin user with a given role.
* If user already exists, returns existing user info.
*/
export async function createAdminUser(adminToken, { email, password, firstname, lastname, roleName }) {
// Check if user already exists
const { data: existing } = await fetchJSON(`${STRAPI_URL}/admin/users?filters[email]=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
if (existing?.data?.results?.length > 0) {
const user = existing.data.results[0];
// Try to login to get their token
const loginResult = await adminLogin(email, password);
return { user, token: loginResult?.token || null, existed: true };
}
// Find the role
const role = await findRoleByName(adminToken, roleName);
if (!role) {
throw new Error(`Role "${roleName}" not found. Available roles: ${(await listAdminRoles(adminToken)).map((r) => r.name).join(', ')}`);
}
// Create user
const { status, data } = await fetchJSON(`${STRAPI_URL}/admin/users`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}` },
body: JSON.stringify({
email,
password,
firstname: firstname || email.split('@')[0],
lastname: lastname || 'Test',
roles: [role.id],
isActive: true,
}),
});
if (status === 201 && data?.data) {
const loginResult = await adminLogin(email, password);
return { user: data.data, token: loginResult?.token || null, existed: false };
}
throw new Error(`Failed to create user ${email}: HTTP ${status} — ${JSON.stringify(data)}`);
}
/**
* Delete admin user by email (cleanup). Returns true on success.
*/
export async function deleteAdminUser(adminToken, email) {
const { data } = await fetchJSON(`${STRAPI_URL}/admin/users?filters[email]=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
if (!data?.data?.results?.length) return false;
const userId = data.data.results[0].id;
const { status } = await fetchJSON(`${STRAPI_URL}/admin/users/${userId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${adminToken}` },
});
return status === 200;
}
// ─── MCP Session Helpers ─────────────────────────────────────────────────────
/**
* MCP Streamable HTTP client for testing.
*
* Usage:
* const mcp = new McpTestClient(adminToken);
* await mcp.initialize();
* const result = await mcp.callTool('list_content_types', {});
* await mcp.close();
*/
export class McpTestClient {
constructor(authToken, options = {}) {
this.authToken = authToken;
this.sessionId = null;
this.requestId = 0;
this.internalSecret = options.internalSecret || null;
}
_headers() {
const h = { 'Content-Type': 'application/json' };
if (this.authToken) h['Authorization'] = `Bearer ${this.authToken}`;
if (this.sessionId) h['mcp-session-id'] = this.sessionId;
if (this.internalSecret) h['x-mcp-internal-secret'] = this.internalSecret;
return h;
}
async initialize() {
const body = {
jsonrpc: '2.0',
id: ++this.requestId,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'mcp-test-client', version: '1.0.0' },
},
};
const resp = await fetch(MCP_ENDPOINT, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify(body),
});
this.sessionId = resp.headers.get('mcp-session-id');
const data = await resp.json().catch(() => null);
return { status: resp.status, data, sessionId: this.sessionId };
}
async callTool(toolName, args = {}) {
if (!this.sessionId) {
throw new Error('Must call initialize() before callTool()');
}
const body = {
jsonrpc: '2.0',
id: ++this.requestId,
method: 'tools/call',
params: { name: toolName, arguments: args },
};
const resp = await fetch(MCP_ENDPOINT, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => null);
return { status: resp.status, data };
}
async listTools() {
if (!this.sessionId) {
throw new Error('Must call initialize() before listTools()');
}
const body = {
jsonrpc: '2.0',
id: ++this.requestId,
method: 'tools/list',
params: {},
};
const resp = await fetch(MCP_ENDPOINT, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => null);
return { status: resp.status, data };
}
async close() {
if (!this.sessionId) return;
try {
await fetch(MCP_ENDPOINT, {
method: 'DELETE',
headers: this._headers(),
});
} catch {
// Ignore close errors
}
this.sessionId = null;
}
/**
* Parse the text content from an MCP tool response.
*/
static parseResult(response) {
const content = response?.data?.result?.content;
if (!content || !Array.isArray(content) || content.length === 0) return null;
const text = content[0]?.text;
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
static isError(response) {
return response?.data?.result?.isError === true || !!response?.data?.error;
}
}
// ─── Content Type Helpers ────────────────────────────────────────────────────
/**
* Find the first API collection type with entries.
* Returns { uid, singularName, pluralName } or null.
*/
export async function findCollectionTypeWithEntries(mcp) {
const typesRes = await mcp.callTool('list_content_types', {});
const types = McpTestClient.parseResult(typesRes);
if (!Array.isArray(types)) return null;
const collections = types.filter((t) => t.kind === 'collectionType');
for (const ct of collections) {
const entriesRes = await mcp.callTool('get_entries', {
contentType: ct.uid,
pageSize: 1,
});
const result = McpTestClient.parseResult(entriesRes);
if (result?.data?.length > 0) {
return {
uid: ct.uid,
singularName: ct.singularName,
displayName: ct.displayName,
firstEntry: result.data[0],
};
}
}
// Return first collection even if empty
if (collections.length > 0) {
return {
uid: collections[0].uid,
singularName: collections[0].singularName,
displayName: collections[0].displayName,
firstEntry: null,
};
}
return null;
}
// ─── Prerequisite Checks ─────────────────────────────────────────────────────
/**
* Verify Strapi is running and reachable.
*/
export async function checkStrapiHealth() {
try {
const resp = await fetch(`${STRAPI_URL}/_health`, { signal: AbortSignal.timeout(5000) });
return resp.status === 204 || resp.status === 200;
} catch {
return false;
}
}
/**
* Ensure Strapi is running or exit with a helpful message.
*/
export async function requireStrapi() {
const healthy = await checkStrapiHealth();
if (!healthy) {
console.error(`\x1b[31mError: Strapi is not reachable at ${STRAPI_URL}\x1b[0m`);
console.error('Start Strapi first: docker compose up -d');
console.error('Or set STRAPI_URL to the correct URL.');
process.exit(1);
}
}