forked from wangziqi/gongxue-base
287 lines
10 KiB
JavaScript
287 lines
10 KiB
JavaScript
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
|
|
function envString(env, key, fallback = '') {
|
|
return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback;
|
|
}
|
|
|
|
function envBoolean(env, key, fallback = false) {
|
|
const value = envString(env, key).toLowerCase();
|
|
if (!value) return fallback;
|
|
return ['1', 'true', 'yes', 'on'].includes(value);
|
|
}
|
|
|
|
function envNumber(env, key, fallback) {
|
|
const value = Number(envString(env, key));
|
|
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
function redactToken(value) {
|
|
if (!value) return '<empty>';
|
|
if (value.length <= 16) return '<redacted>';
|
|
return `${value.slice(0, 6)}...${value.slice(-6)}`;
|
|
}
|
|
|
|
function buildConfig(env = process.env) {
|
|
const apiBaseUrl = envString(env, 'AUTH_SMOKE_API_BASE_URL', envString(env, 'API_BASE', ''));
|
|
const tenantId = envString(env, 'AUTH_SMOKE_TENANT_ID', envString(env, 'TENANT_ID', ''));
|
|
const studentToken = envString(env, 'AUTH_SMOKE_STUDENT_ACCESS_TOKEN', envString(env, 'SUPABASE_ACCESS_TOKEN', ''));
|
|
const tenantAdminToken = envString(env, 'AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN');
|
|
const platformAdminToken = envString(env, 'AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN');
|
|
const requireAdminTokens = envBoolean(env, 'AUTH_SMOKE_REQUIRE_ADMIN_TOKENS', false);
|
|
|
|
const missing = [];
|
|
if (!apiBaseUrl) missing.push('AUTH_SMOKE_API_BASE_URL');
|
|
if (!tenantId) missing.push('AUTH_SMOKE_TENANT_ID');
|
|
if (!studentToken) missing.push('AUTH_SMOKE_STUDENT_ACCESS_TOKEN');
|
|
if (requireAdminTokens && !tenantAdminToken) missing.push('AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN');
|
|
if (requireAdminTokens && !platformAdminToken) missing.push('AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN');
|
|
|
|
if (missing.length > 0) {
|
|
throw new Error(`Missing required remote auth smoke env: ${missing.join(', ')}`);
|
|
}
|
|
|
|
return {
|
|
apiBaseUrl: normalizeBaseUrl(apiBaseUrl),
|
|
tenantId,
|
|
wrongTenantId: envString(env, 'AUTH_SMOKE_WRONG_TENANT_ID'),
|
|
timeoutMs: envNumber(env, 'AUTH_SMOKE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
|
|
requireAdminTokens,
|
|
student: {
|
|
token: studentToken,
|
|
expectedUserId: envString(env, 'AUTH_SMOKE_EXPECTED_STUDENT_USER_ID'),
|
|
},
|
|
tenantAdmin: tenantAdminToken
|
|
? {
|
|
token: tenantAdminToken,
|
|
expectedUserId: envString(env, 'AUTH_SMOKE_EXPECTED_TENANT_ADMIN_USER_ID'),
|
|
}
|
|
: null,
|
|
platformAdmin: platformAdminToken
|
|
? {
|
|
token: platformAdminToken,
|
|
expectedUserId: envString(env, 'AUTH_SMOKE_EXPECTED_PLATFORM_ADMIN_USER_ID'),
|
|
tenantId: envString(env, 'AUTH_SMOKE_PLATFORM_TENANT_ID', tenantId),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function authHeaders(token, tenantId) {
|
|
return {
|
|
authorization: `Bearer ${token}`,
|
|
...(tenantId ? { 'x-tenant-id': tenantId } : {}),
|
|
};
|
|
}
|
|
|
|
async function requestJson(config, path, { token, tenantId = config.tenantId, method = 'GET' } = {}) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
const url = new URL(path, config.apiBaseUrl);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
accept: 'application/json',
|
|
...(token ? authHeaders(token, tenantId) : {}),
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
const text = await response.text();
|
|
let payload = {};
|
|
if (text.trim()) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = { raw: text.slice(0, 500) };
|
|
}
|
|
}
|
|
return { status: response.status, ok: response.ok, payload };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function resultCollector({ quiet = false } = {}) {
|
|
const results = [];
|
|
function pass(name, detail = {}) {
|
|
results.push({ status: 'pass', name, detail });
|
|
if (!quiet) console.log(`PASS ${name}`);
|
|
}
|
|
function fail(name, message, detail = {}) {
|
|
results.push({ status: 'fail', name, message, detail });
|
|
if (!quiet) {
|
|
console.error(`FAIL ${name}: ${message}`);
|
|
if (Object.keys(detail).length > 0) console.error(JSON.stringify(detail, null, 2));
|
|
}
|
|
}
|
|
return { results, pass, fail };
|
|
}
|
|
|
|
function expectStatus(collector, name, response, expectedStatuses) {
|
|
const expected = Array.isArray(expectedStatuses) ? expectedStatuses : [expectedStatuses];
|
|
if (expected.includes(response.status)) {
|
|
collector.pass(name, { status: response.status });
|
|
return true;
|
|
}
|
|
collector.fail(name, `Expected HTTP ${expected.join('/')} but got ${response.status}`, { payload: response.payload });
|
|
return false;
|
|
}
|
|
|
|
function expectSupabaseJwtSession(collector, name, response, expectedUserId = '') {
|
|
if (!expectStatus(collector, `${name}.status`, response, 200)) return;
|
|
const source = response.payload?.session?.source;
|
|
if (source === 'supabase_jwt') collector.pass(`${name}.session_source`);
|
|
else collector.fail(`${name}.session_source`, 'Expected session.source=supabase_jwt', { source });
|
|
|
|
if (expectedUserId) {
|
|
const actualUserId = response.payload?.user?.id || response.payload?.item?.userId;
|
|
if (actualUserId === expectedUserId) collector.pass(`${name}.expected_user`);
|
|
else collector.fail(`${name}.expected_user`, 'Authenticated user does not match expected id', { actualUserId, expectedUserId });
|
|
}
|
|
}
|
|
|
|
async function runRemoteAuthSmoke(inputConfig, options = {}) {
|
|
const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env);
|
|
const collector = resultCollector({ quiet: options.quiet });
|
|
|
|
if (!options.quiet) {
|
|
console.log(`Remote auth smoke target: ${config.apiBaseUrl}`);
|
|
console.log(`Student token: ${redactToken(config.student.token)}`);
|
|
if (config.tenantAdmin) console.log(`Tenant admin token: ${redactToken(config.tenantAdmin.token)}`);
|
|
if (config.platformAdmin) console.log(`Platform admin token: ${redactToken(config.platformAdmin.token)}`);
|
|
}
|
|
|
|
const studentMe = await requestJson(config, '/api/auth/me', {
|
|
token: config.student.token,
|
|
tenantId: config.tenantId,
|
|
});
|
|
expectSupabaseJwtSession(collector, 'student.auth_me', studentMe, config.student.expectedUserId);
|
|
|
|
const studentProfile = await requestJson(config, '/api/profile/me', {
|
|
token: config.student.token,
|
|
tenantId: config.tenantId,
|
|
});
|
|
expectStatus(collector, 'student.profile_me', studentProfile, 200);
|
|
if (config.student.expectedUserId && studentProfile.payload?.item?.userId !== config.student.expectedUserId) {
|
|
collector.fail('student.profile_expected_user', 'Profile user does not match expected student id', {
|
|
actualUserId: studentProfile.payload?.item?.userId,
|
|
expectedUserId: config.student.expectedUserId,
|
|
});
|
|
} else {
|
|
collector.pass('student.profile_expected_user');
|
|
}
|
|
|
|
const studentTenantAdmin = await requestJson(config, '/api/tenant-admin/overview', {
|
|
token: config.student.token,
|
|
tenantId: config.tenantId,
|
|
});
|
|
expectStatus(collector, 'student.tenant_admin_denied', studentTenantAdmin, 403);
|
|
|
|
const studentPlatformAdmin = await requestJson(config, '/api/platform-admin/overview', {
|
|
token: config.student.token,
|
|
tenantId: '',
|
|
});
|
|
expectStatus(collector, 'student.platform_admin_denied', studentPlatformAdmin, 403);
|
|
|
|
const badTokenProfile = await requestJson(config, '/api/profile/me', {
|
|
token: 'invalid.jwt.token',
|
|
tenantId: config.tenantId,
|
|
});
|
|
expectStatus(collector, 'invalid_token.profile_denied', badTokenProfile, 401);
|
|
|
|
if (config.wrongTenantId) {
|
|
const wrongTenantProfile = await requestJson(config, '/api/profile/me', {
|
|
token: config.student.token,
|
|
tenantId: config.wrongTenantId,
|
|
});
|
|
expectStatus(collector, 'student.wrong_tenant_denied', wrongTenantProfile, [401, 403, 404]);
|
|
}
|
|
|
|
if (config.tenantAdmin) {
|
|
const tenantAdminMe = await requestJson(config, '/api/auth/me', {
|
|
token: config.tenantAdmin.token,
|
|
tenantId: config.tenantId,
|
|
});
|
|
expectSupabaseJwtSession(collector, 'tenant_admin.auth_me', tenantAdminMe, config.tenantAdmin.expectedUserId);
|
|
|
|
const tenantOverview = await requestJson(config, '/api/tenant-admin/overview', {
|
|
token: config.tenantAdmin.token,
|
|
tenantId: config.tenantId,
|
|
});
|
|
expectStatus(collector, 'tenant_admin.overview', tenantOverview, 200);
|
|
|
|
const tenantPlatformAdmin = await requestJson(config, '/api/platform-admin/overview', {
|
|
token: config.tenantAdmin.token,
|
|
tenantId: '',
|
|
});
|
|
expectStatus(collector, 'tenant_admin.platform_admin_denied', tenantPlatformAdmin, 403);
|
|
} else {
|
|
collector.pass('tenant_admin.optional_skipped');
|
|
}
|
|
|
|
if (config.platformAdmin) {
|
|
const platformOverview = await requestJson(config, '/api/platform-admin/overview', {
|
|
token: config.platformAdmin.token,
|
|
tenantId: '',
|
|
});
|
|
expectStatus(collector, 'platform_admin.overview', platformOverview, 200);
|
|
|
|
if (config.platformAdmin.tenantId) {
|
|
const platformMe = await requestJson(config, '/api/auth/me', {
|
|
token: config.platformAdmin.token,
|
|
tenantId: config.platformAdmin.tenantId,
|
|
});
|
|
expectSupabaseJwtSession(collector, 'platform_admin.auth_me', platformMe, config.platformAdmin.expectedUserId);
|
|
}
|
|
} else {
|
|
collector.pass('platform_admin.optional_skipped');
|
|
}
|
|
|
|
const failed = collector.results.filter(item => item.status === 'fail');
|
|
if (!options.quiet) {
|
|
console.log(`\nRemote Auth/JWKS smoke checks: ${collector.results.length - failed.length} passed, ${failed.length} failed.`);
|
|
}
|
|
if (failed.length > 0) {
|
|
const error = new Error(`Remote Auth/JWKS smoke failed with ${failed.length} failing checks`);
|
|
error.results = collector.results;
|
|
throw error;
|
|
}
|
|
return collector.results;
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
await runRemoteAuthSmoke();
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
if (/Missing required remote auth smoke env/.test(error.message)) {
|
|
console.error(`
|
|
Required example:
|
|
AUTH_SMOKE_API_BASE_URL=https://api.example.com
|
|
AUTH_SMOKE_TENANT_ID=<tenant-uuid>
|
|
AUTH_SMOKE_STUDENT_ACCESS_TOKEN=<real-supabase-access-token>
|
|
|
|
Recommended full pre-production example:
|
|
AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN=<tenant-admin-supabase-access-token>
|
|
AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN=<platform-admin-supabase-access-token>
|
|
AUTH_SMOKE_WRONG_TENANT_ID=<another-tenant-uuid>
|
|
AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true
|
|
`);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const currentFile = fileURLToPath(import.meta.url);
|
|
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
|
|
await main();
|
|
}
|
|
|
|
export { buildConfig, runRemoteAuthSmoke };
|