feat: enforce trusted session identity

This commit is contained in:
Codex
2026-06-28 21:56:11 +08:00
parent 1d873b2e50
commit 523b63c53b
23 changed files with 502 additions and 201 deletions

View File

@@ -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);