forked from wangziqi/gongxue-base
feat: enforce trusted session identity
This commit is contained in:
@@ -34,9 +34,15 @@ const ids = {
|
||||
let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787';
|
||||
let serverProcess = null;
|
||||
let serverLogs = '';
|
||||
let legacyDisabledServer = null;
|
||||
let legacyDisabledServerLogs = '';
|
||||
|
||||
function buildUrl(path, query = {}) {
|
||||
const target = new URL(path, apiBase);
|
||||
return buildUrlAt(apiBase, path, query);
|
||||
}
|
||||
|
||||
function buildUrlAt(baseUrl, path, query = {}) {
|
||||
const target = new URL(path, baseUrl);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
target.searchParams.set(key, String(value));
|
||||
@@ -68,6 +74,29 @@ async function request(path, options = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAt(baseUrl, path, options = {}) {
|
||||
const response = await fetch(buildUrlAt(baseUrl, path, options.query), {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || MAIN_TENANT_ID }),
|
||||
...(options.userId === false ? {} : { 'x-user-id': options.userId || USER_ID }),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (options.expectStatus) {
|
||||
assert.equal(response.status, options.expectStatus, `${options.method || 'GET'} ${path} should return ${options.expectStatus}`);
|
||||
return payload;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${options.method || 'GET'} ${path} failed: ${response.status} ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function check(name, fn) {
|
||||
await fn();
|
||||
console.log(`[PASS] ${name}`);
|
||||
@@ -99,6 +128,21 @@ async function waitForHealth(timeoutMs = 12_000) {
|
||||
throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`);
|
||||
}
|
||||
|
||||
async function waitForHealthAt(baseUrl, logsRef, timeoutMs = 12_000) {
|
||||
const started = Date.now();
|
||||
let lastError = null;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const payload = await requestAt(baseUrl, '/health', { userId: false });
|
||||
if (payload.ok) return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${logsRef()}`);
|
||||
}
|
||||
|
||||
async function startServerIfNeeded() {
|
||||
if (!START_SERVER) return;
|
||||
const port = Number(process.env.TEST_API_PORT || 0) || await getFreePort();
|
||||
@@ -126,6 +170,36 @@ async function startServerIfNeeded() {
|
||||
await waitForHealth();
|
||||
}
|
||||
|
||||
async function startLegacyDisabledServer() {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
legacyDisabledServerLogs = '';
|
||||
legacyDisabledServer = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
|
||||
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
|
||||
ALLOW_LEGACY_AUTH_HEADERS: 'false',
|
||||
ALLOW_PLATFORM_ADMIN_KEY: 'false',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
legacyDisabledServer.stdout.on('data', chunk => {
|
||||
legacyDisabledServerLogs += chunk.toString();
|
||||
});
|
||||
legacyDisabledServer.stderr.on('data', chunk => {
|
||||
legacyDisabledServerLogs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForHealthAt(baseUrl, () => legacyDisabledServerLogs);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -176,10 +250,122 @@ async function testProductionConfigFailFast() {
|
||||
assert.match(logs, /Invalid production API configuration/, 'production fail-fast should explain unsafe config');
|
||||
}
|
||||
|
||||
async function loginBySms(phone = '13800000000') {
|
||||
const sent = await request('/api/auth/sms/send', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: { phone, purpose: 'login' },
|
||||
});
|
||||
assert.ok(sent.debugCode, 'mock SMS login should expose debugCode in local tests');
|
||||
|
||||
const verified = await request('/api/auth/sms/verify', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: { phone, code: sent.debugCode, purpose: 'login' },
|
||||
});
|
||||
assert.ok(verified.session?.token, 'SMS verify should issue a session token');
|
||||
return verified;
|
||||
}
|
||||
|
||||
async function testTrustedSessionIdentity() {
|
||||
const login = await loginBySms();
|
||||
assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user');
|
||||
const authHeaders = { authorization: `Bearer ${login.session.token}` };
|
||||
|
||||
const me = await request('/api/auth/me', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
});
|
||||
assert.equal(me.user?.id, USER_ID, 'auth/me should resolve user from bearer session');
|
||||
|
||||
const profile = await request('/api/profile/me', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
});
|
||||
assert.equal(profile.item?.userId, USER_ID, 'profile should resolve user from bearer session without x-user-id');
|
||||
|
||||
const vocabularyProgress = await request('/api/learning/vocabulary/progress', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
method: 'POST',
|
||||
body: { wordId: ids.vocabularyWord, status: 'learning', correctDelta: 0 },
|
||||
});
|
||||
assert.equal(vocabularyProgress.item?.wordId, ids.vocabularyWord, 'learning APIs should accept bearer session identity');
|
||||
const vocabularyProgressList = await request('/api/learning/vocabulary/progress', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
query: { unitId: ids.vocabularyUnit },
|
||||
});
|
||||
assert.ok(
|
||||
vocabularyProgressList.items?.some(item => item.wordId === ids.vocabularyWord),
|
||||
'learning APIs should read progress through bearer session identity',
|
||||
);
|
||||
|
||||
const spoofedUser = await request('/api/profile/me', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
headers: authHeaders,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(spoofedUser.code, 'AUTH_USER_MISMATCH', 'session requests must reject spoofed x-user-id');
|
||||
|
||||
const spoofedBodyUser = await request('/api/learning/vocabulary/progress', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
wordId: ids.vocabularyWord,
|
||||
status: 'learning',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(spoofedBodyUser.code, 'AUTH_USER_MISMATCH', 'session requests must reject spoofed body userId');
|
||||
|
||||
const spoofedTenant = await request('/api/profile/me', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(spoofedTenant.code, 'AUTH_TENANT_MISMATCH', 'session requests must reject spoofed tenant');
|
||||
|
||||
const invalidSession = await request('/api/profile/me', {
|
||||
headers: { authorization: 'Bearer tk_invalid_session_token' },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
|
||||
}
|
||||
|
||||
async function testLegacyAuthHeadersDisabled() {
|
||||
const login = await loginBySms('13800000006');
|
||||
const baseUrl = await startLegacyDisabledServer();
|
||||
const authHeaders = { authorization: `Bearer ${login.session.token}` };
|
||||
|
||||
const legacyProfile = await requestAt(baseUrl, '/api/profile/me', {
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(legacyProfile.code, 'TRUSTED_USER_REQUIRED', 'legacy x-user-id should be disabled when configured off');
|
||||
|
||||
const trustedProfile = await requestAt(baseUrl, '/api/profile/me', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
});
|
||||
assert.equal(trustedProfile.item?.userId, login.user.id, 'trusted session should still work when legacy headers are disabled');
|
||||
|
||||
const legacyAdminKey = await requestAt(baseUrl, '/api/platform-admin/overview', {
|
||||
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(legacyAdminKey.code, 'PLATFORM_ADMIN_KEY_DISABLED', 'platform admin key should be disabled when configured off');
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
serverProcess.kill();
|
||||
}
|
||||
if (legacyDisabledServer && !legacyDisabledServer.killed) {
|
||||
legacyDisabledServer.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function testCatalogAndLearning() {
|
||||
@@ -1769,6 +1955,8 @@ async function main() {
|
||||
console.log(`[INFO] API integration target: ${apiBase}`);
|
||||
|
||||
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
|
||||
await check('trusted session identity', testTrustedSessionIdentity);
|
||||
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
|
||||
await check('catalog and learning', testCatalogAndLearning);
|
||||
await check('profile', testProfile);
|
||||
await check('scoreline', testScoreline);
|
||||
|
||||
Reference in New Issue
Block a user