forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
@@ -6,8 +6,14 @@ import net from 'node:net';
|
||||
import pg from 'pg';
|
||||
import { SignJWT, exportJWK } from 'jose';
|
||||
import writeXlsxFile from 'write-excel-file/node';
|
||||
import {
|
||||
assertDestructiveTestDatabase,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL;
|
||||
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
|
||||
const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
const PARTNER_TENANT_ID = process.env.PARTNER_TENANT_ID || '00000000-0000-0000-0000-000000000901';
|
||||
const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101';
|
||||
@@ -108,6 +114,8 @@ let jwksServer = null;
|
||||
let fakeWechatServer = null;
|
||||
let fakeQqServer = null;
|
||||
let fakeWechatPayServer = null;
|
||||
let tenantPresentationSnapshot = null;
|
||||
const providerBillJobIds = new Set();
|
||||
|
||||
function buildUrl(path, query = {}) {
|
||||
return buildUrlAt(apiBase, path, query);
|
||||
@@ -250,6 +258,162 @@ async function setTenantFeatureFlag(tenantId, flag, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
async function insertIntegrationAuthSession(tenantId, userId, source) {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
insert into app_private.auth_sessions (
|
||||
tenant_id, user_id, token_hash, provider, expires_at, metadata
|
||||
)
|
||||
values ($1, $2, $3, 'integration-test', now() + interval '1 hour', $4::jsonb)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
crypto.createHash('sha256').update(`${source}:${crypto.randomUUID()}`).digest('hex'),
|
||||
JSON.stringify({ source: 'api-integration-test', scenario: source }),
|
||||
],
|
||||
);
|
||||
return result.rows[0].id;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function assertIntegrationAuthSessionRevoked(sessionId, message) {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`select revoked_at as "revokedAt" from app_private.auth_sessions where id = $1`,
|
||||
[sessionId],
|
||||
);
|
||||
assert.ok(result.rows[0]?.revokedAt, message);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupIntegrationAuthSessions() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
await pool.query(
|
||||
`delete from app_private.auth_sessions where metadata->>'source' = 'api-integration-test'`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupSmsIntegrationRateLimits() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
await pool.query('delete from app_private.sms_send_rate_limits');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function assertLocalIntegrationTarget() {
|
||||
if (!START_SERVER) {
|
||||
throw new Error(
|
||||
'API integration tests are destructive and must use --start-server. Use the dedicated remote smoke commands for deployed environments.',
|
||||
);
|
||||
}
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await assertDestructiveTestDatabase({
|
||||
client,
|
||||
databaseUrl,
|
||||
confirmation: destructiveTestConfirmation,
|
||||
operation: 'API integration test',
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function captureTenantPresentationSnapshot() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
select
|
||||
(select row_to_json(b) from public.tenant_branding b where b.tenant_id = $1) as branding,
|
||||
(select row_to_json(c) from public.tenant_theme_configs c where c.tenant_id = $1) as theme
|
||||
`,
|
||||
[MAIN_TENANT_ID],
|
||||
);
|
||||
return result.rows[0] || { branding: null, theme: null };
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreTenantPresentationSnapshot(snapshot) {
|
||||
if (!snapshot) return;
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
await client.query('delete from public.tenant_theme_configs where tenant_id = $1', [MAIN_TENANT_ID]);
|
||||
await client.query('delete from public.tenant_branding where tenant_id = $1', [MAIN_TENANT_ID]);
|
||||
if (snapshot.branding) {
|
||||
await client.query(
|
||||
`insert into public.tenant_branding select * from json_populate_record(null::public.tenant_branding, $1::json)`,
|
||||
[JSON.stringify(snapshot.branding)],
|
||||
);
|
||||
}
|
||||
if (snapshot.theme) {
|
||||
await client.query(
|
||||
`insert into public.tenant_theme_configs select * from json_populate_record(null::public.tenant_theme_configs, $1::json)`,
|
||||
[JSON.stringify(snapshot.theme)],
|
||||
);
|
||||
}
|
||||
await client.query('commit');
|
||||
} catch (error) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupProviderBillJobs(billDate = '') {
|
||||
const idsToDelete = [...providerBillJobIds];
|
||||
if (!billDate && idsToDelete.length === 0) return;
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.commerce_bill_download_jobs
|
||||
where tenant_id = $1
|
||||
and (
|
||||
id = any($2::uuid[])
|
||||
or (
|
||||
nullif($3::text, '')::date is not null
|
||||
and provider = 'wechat_pay'
|
||||
and bill_date = nullif($3::text, '')::date
|
||||
and bill_type = 'payment'
|
||||
and metadata->>'source' = 'api-integration-test'
|
||||
)
|
||||
)
|
||||
`,
|
||||
[MAIN_TENANT_ID, idsToDelete, billDate],
|
||||
);
|
||||
idsToDelete.forEach(id => providerBillJobIds.delete(id));
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -313,6 +477,11 @@ async function startServerIfNeeded() {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
AUTH_SMS_PROVIDER: 'mock',
|
||||
AUTH_SMS_TENANT_DAILY_LIMIT: '100000',
|
||||
AUTH_SMS_PHONE_DAILY_LIMIT: '1000',
|
||||
AUTH_SMS_IP_HOURLY_LIMIT: '100000',
|
||||
AUTH_SMS_DEVICE_HOURLY_LIMIT: '1000',
|
||||
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
|
||||
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
|
||||
},
|
||||
@@ -340,6 +509,7 @@ async function startLegacyDisabledServer() {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
AUTH_SMS_PROVIDER: 'mock',
|
||||
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',
|
||||
@@ -370,6 +540,7 @@ async function startJwksAuthServer(jwksUrl) {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
AUTH_SMS_PROVIDER: 'mock',
|
||||
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
|
||||
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
|
||||
AUTH_JWT_JWKS_URL: jwksUrl,
|
||||
@@ -873,9 +1044,10 @@ async function testProductionConfigFailFast() {
|
||||
assert.match(logs, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'production fail-fast should reject local_dev storage');
|
||||
}
|
||||
|
||||
async function loginBySms(phone = '13800000000') {
|
||||
async function loginBySms(phone = '13800000000', options = {}) {
|
||||
const sent = await request('/api/auth/sms/send', {
|
||||
userId: false,
|
||||
tenantId: options.tenantId,
|
||||
method: 'POST',
|
||||
body: { phone, purpose: 'login' },
|
||||
});
|
||||
@@ -883,6 +1055,7 @@ async function loginBySms(phone = '13800000000') {
|
||||
|
||||
const verified = await request('/api/auth/sms/verify', {
|
||||
userId: false,
|
||||
tenantId: options.tenantId,
|
||||
method: 'POST',
|
||||
body: { phone, code: sent.debugCode, purpose: 'login' },
|
||||
});
|
||||
@@ -900,6 +1073,86 @@ async function sendMockSmsCode(phone, purpose) {
|
||||
return sent.debugCode;
|
||||
}
|
||||
|
||||
async function testConcurrentSmsSendReservation() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
|
||||
const tenantId = crypto.randomUUID();
|
||||
const tenantSlug = `sms-concurrency-${Date.now().toString(36)}`;
|
||||
const phone = `139${String(Date.now()).slice(-8)}`;
|
||||
const deviceId = `api-integration-sms-${crypto.randomUUID()}`;
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`insert into public.tenants (id, slug, name, status) values ($1, $2, 'SMS Concurrency Tenant', 'active')`,
|
||||
[tenantId, tenantSlug],
|
||||
);
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 8 }, () => fetch(buildUrl('/api/auth/sms/send'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-tenant-id': tenantId,
|
||||
'x-forwarded-for': '198.51.100.27',
|
||||
},
|
||||
body: JSON.stringify({ phone, purpose: 'login', deviceId }),
|
||||
})),
|
||||
);
|
||||
const results = await Promise.all(
|
||||
responses.map(async response => ({
|
||||
status: response.status,
|
||||
payload: await response.json().catch(() => ({})),
|
||||
})),
|
||||
);
|
||||
const accepted = results.filter(result => result.status === 200);
|
||||
const rejected = results.filter(result => result.status === 429);
|
||||
|
||||
assert.equal(accepted.length, 1, 'concurrent SMS sends must incur provider cost exactly once');
|
||||
assert.equal(rejected.length, 7, 'all duplicate concurrent SMS sends must be rate limited');
|
||||
assert.ok(accepted[0]?.payload?.debugCode, 'the accepted mock SMS send should expose a debug code');
|
||||
assert.ok(
|
||||
rejected.every(result => result.payload?.code === 'SMS_COOLDOWN'),
|
||||
'duplicate concurrent SMS sends must fail through the cooldown reservation',
|
||||
);
|
||||
|
||||
const activeReservations = await pool.query(
|
||||
`
|
||||
select count(*)::integer as count
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = 'login'
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
`,
|
||||
[tenantId, phone],
|
||||
);
|
||||
assert.equal(activeReservations.rows[0]?.count, 1, 'database must retain one active SMS reservation');
|
||||
|
||||
const quotaBuckets = await pool.query(
|
||||
`
|
||||
select dimension, request_count as "requestCount"
|
||||
from app_private.sms_send_rate_limits
|
||||
where tenant_id = $1
|
||||
order by dimension
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quotaBuckets.rows,
|
||||
[
|
||||
{ dimension: 'device', requestCount: 1 },
|
||||
{ dimension: 'ip', requestCount: 1 },
|
||||
{ dimension: 'phone', requestCount: 1 },
|
||||
{ dimension: 'tenant', requestCount: 1 },
|
||||
],
|
||||
'rolled-back duplicate sends must not consume extra SMS quota',
|
||||
);
|
||||
} finally {
|
||||
await pool.query('delete from public.tenants where id = $1', [tenantId]).catch(() => undefined);
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function testTrustedSessionIdentity() {
|
||||
const login = await loginBySms();
|
||||
assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user');
|
||||
@@ -969,6 +1222,136 @@ async function testTrustedSessionIdentity() {
|
||||
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
|
||||
}
|
||||
|
||||
async function testAuthStatusEnforcement() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
const original = await pool.query(
|
||||
`
|
||||
select t.status as "tenantStatus", u.status as "userStatus", tm.status as "membershipStatus"
|
||||
from public.tenants t
|
||||
join public.platform_users u on u.id = $2
|
||||
join public.tenant_memberships tm
|
||||
on tm.tenant_id = t.id and tm.user_id = u.id and tm.role = 'student'
|
||||
where t.id = $1
|
||||
`,
|
||||
[MAIN_TENANT_ID, USER_ID],
|
||||
);
|
||||
const snapshot = original.rows[0];
|
||||
assert.ok(snapshot, 'auth status test requires the smoke student membership');
|
||||
|
||||
try {
|
||||
const membershipLogin = await loginBySms();
|
||||
await pool.query(
|
||||
`update public.tenant_memberships set status = 'disabled', updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and role = 'student'`,
|
||||
[MAIN_TENANT_ID, USER_ID],
|
||||
);
|
||||
const disabledMembershipSession = await request('/api/auth/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${membershipLogin.session.token}` },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(disabledMembershipSession.code, 'AUTH_SESSION_INVALID', 'disabled membership must invalidate existing app sessions');
|
||||
|
||||
const sentForDisabledMembership = await request('/api/auth/sms/send', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: { phone: '13800000000', purpose: 'login' },
|
||||
});
|
||||
const disabledMembershipLogin = await request('/api/auth/sms/verify', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: { phone: '13800000000', code: sentForDisabledMembership.debugCode, purpose: 'login' },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(disabledMembershipLogin.code, 'AUTH_MEMBERSHIP_INACTIVE', 'login must not reactivate a disabled membership');
|
||||
const membershipAfterLogin = await pool.query(
|
||||
`select status from public.tenant_memberships where tenant_id = $1 and user_id = $2 and role = 'student'`,
|
||||
[MAIN_TENANT_ID, USER_ID],
|
||||
);
|
||||
assert.equal(membershipAfterLogin.rows[0]?.status, 'disabled', 'failed login must preserve disabled membership status');
|
||||
|
||||
await pool.query(
|
||||
`update public.tenant_memberships set status = 'invited', updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and role = 'student'`,
|
||||
[MAIN_TENANT_ID, USER_ID],
|
||||
);
|
||||
const invitedMembershipLogin = await request('/api/auth/sms/verify', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: { phone: '13800000000', code: sentForDisabledMembership.debugCode, purpose: 'login' },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(invitedMembershipLogin.code, 'AUTH_MEMBERSHIP_INACTIVE', 'login must not activate an invited membership');
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = '13800000000'
|
||||
and consumed_at is null
|
||||
`,
|
||||
[MAIN_TENANT_ID],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`update public.tenant_memberships set status = 'active', updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and role = 'student'`,
|
||||
[MAIN_TENANT_ID, USER_ID],
|
||||
);
|
||||
await pool.query(`update public.platform_users set status = 'disabled', updated_at = now() where id = $1`, [USER_ID]);
|
||||
const disabledUserJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
|
||||
const disabledUserDenied = await request('/api/auth/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${disabledUserJwt}` },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(disabledUserDenied.code, 'AUTH_SESSION_INVALID', 'disabled platform user must not authenticate with Supabase JWT');
|
||||
|
||||
await pool.query(`update public.platform_users set status = 'active', updated_at = now() where id = $1`, [USER_ID]);
|
||||
await pool.query(`update public.tenants set status = 'suspended', updated_at = now() where id = $1`, [MAIN_TENANT_ID]);
|
||||
const suspendedTenantJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
|
||||
const suspendedTenantDenied = await request('/api/auth/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${suspendedTenantJwt}` },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(suspendedTenantDenied.code, 'AUTH_SESSION_INVALID', 'suspended tenant must reject non-platform JWT identity');
|
||||
|
||||
const newTenantId = crypto.randomUUID();
|
||||
const newTenantSlug = `auth-status-${Date.now().toString(36)}`;
|
||||
const newPhone = `137${String(Date.now()).slice(-8)}`;
|
||||
await pool.query(
|
||||
`insert into public.tenants (id, slug, name, status) values ($1, $2, 'Auth Status Tenant', 'active')`,
|
||||
[newTenantId, newTenantSlug],
|
||||
);
|
||||
const firstLogin = await loginBySms(newPhone, { tenantId: newTenantId });
|
||||
assert.equal(firstLogin.isNewUser, true, 'first tenant login should create an active student membership');
|
||||
const firstMembership = await pool.query(
|
||||
`select status from public.tenant_memberships where tenant_id = $1 and user_id = $2 and role = 'student'`,
|
||||
[newTenantId, firstLogin.user.id],
|
||||
);
|
||||
assert.equal(firstMembership.rows[0]?.status, 'active', 'new login membership must start active');
|
||||
await pool.query('delete from public.tenants where id = $1', [newTenantId]);
|
||||
await pool.query('delete from public.platform_users where id = $1', [firstLogin.user.id]);
|
||||
|
||||
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { phone: '13999999999' });
|
||||
const platformOverview = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${platformAdminJwt}` },
|
||||
});
|
||||
assert.ok(platformOverview.item?.tenants?.total >= 1, 'global platform admin must remain available while a tenant is suspended');
|
||||
} finally {
|
||||
await pool.query(`update public.tenants set status = $2, updated_at = now() where id = $1`, [MAIN_TENANT_ID, snapshot.tenantStatus]);
|
||||
await pool.query(`update public.platform_users set status = $2, updated_at = now() where id = $1`, [USER_ID, snapshot.userStatus]);
|
||||
await pool.query(
|
||||
`update public.tenant_memberships set status = $3, updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and role = 'student'`,
|
||||
[MAIN_TENANT_ID, USER_ID, snapshot.membershipStatus],
|
||||
);
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function testPhoneBinding() {
|
||||
const phoneSuffix = String(Date.now()).slice(-6);
|
||||
const oldPhone = `13920${phoneSuffix}`;
|
||||
@@ -1082,14 +1465,42 @@ async function testSupabaseJwtIdentity() {
|
||||
|
||||
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, {
|
||||
phone: '13999999999',
|
||||
appRole: 'platform_admin',
|
||||
});
|
||||
const platformOverview = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${platformAdminJwt}` },
|
||||
});
|
||||
assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin Supabase JWT should access platform overview');
|
||||
assert.ok(
|
||||
platformOverview.item?.tenants?.total >= 1,
|
||||
'database platform admin should accept a standard Supabase role=authenticated JWT without app_role',
|
||||
);
|
||||
|
||||
const globallyScopedPlatformOverview = await request('/api/platform-admin/overview', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${platformAdminJwt}` },
|
||||
});
|
||||
assert.ok(
|
||||
globallyScopedPlatformOverview.item?.tenants?.total >= 1,
|
||||
'global platform admin should not require membership in a requested tenant context',
|
||||
);
|
||||
|
||||
const elevatedStudentJwt = await createSupabaseJwt(AUTH_USER_ID, {
|
||||
phone: '13800000000',
|
||||
appRole: 'platform_admin',
|
||||
});
|
||||
const elevatedStudentDenied = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${elevatedStudentJwt}` },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(
|
||||
elevatedStudentDenied.code,
|
||||
'PLATFORM_ADMIN_REQUIRED',
|
||||
'JWT app_role must not elevate a non-platform database user',
|
||||
);
|
||||
|
||||
const studentPlatformDenied = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
@@ -1570,6 +1981,7 @@ async function testPlatformTenantOperationsAndAudit() {
|
||||
assert.ok(created.item?.id, 'platform admin should create a tenant');
|
||||
|
||||
const tenantId = created.item.id;
|
||||
const tenantSessionId = await insertIntegrationAuthSession(tenantId, USER_ID, 'platform-tenant-suspend');
|
||||
const detail = await request('/api/platform-admin/tenants/detail', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
@@ -1600,6 +2012,24 @@ async function testPlatformTenantOperationsAndAudit() {
|
||||
});
|
||||
assert.equal(billing.item?.billingName, '集成测试更新主体', 'platform admin should update tenant billing profile');
|
||||
|
||||
const suspended = await request('/api/platform-admin/tenants/status', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
tenantId,
|
||||
status: 'suspended',
|
||||
billingStatus: 'active',
|
||||
reason: 'integration audit coverage',
|
||||
},
|
||||
});
|
||||
assert.equal(suspended.item?.status, 'suspended', 'platform admin should suspend a tenant');
|
||||
await assertIntegrationAuthSessionRevoked(
|
||||
tenantSessionId,
|
||||
'suspending a tenant must revoke all tenant sessions in the same operation',
|
||||
);
|
||||
|
||||
const status = await request('/api/platform-admin/tenants/status', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
@@ -1608,8 +2038,7 @@ async function testPlatformTenantOperationsAndAudit() {
|
||||
body: {
|
||||
tenantId,
|
||||
status: 'active',
|
||||
billingStatus: 'active',
|
||||
reason: 'integration audit coverage',
|
||||
reason: 'restore after session revocation coverage',
|
||||
},
|
||||
});
|
||||
assert.equal(status.item?.billingStatus, 'active', 'platform admin should update tenant billing status');
|
||||
@@ -4186,6 +4615,7 @@ async function testCommerce() {
|
||||
});
|
||||
|
||||
const billDate = shanghaiDateKey();
|
||||
await cleanupProviderBillJobs(billDate);
|
||||
const reconciliationRows = [
|
||||
{
|
||||
transactionType: 'payment',
|
||||
@@ -4441,6 +4871,7 @@ async function testCommerce() {
|
||||
},
|
||||
});
|
||||
assert.ok(providerBillJob.item?.id, 'tenant admin should request official provider bill download job');
|
||||
providerBillJobIds.add(providerBillJob.item.id);
|
||||
assert.equal(providerBillJob.item?.status, 'queued', 'new provider bill job should be queued');
|
||||
assert.equal(providerBillJob.item?.provider, 'wechat_pay', 'provider bill job should keep provider');
|
||||
assert.ok(!JSON.stringify(providerBillJob).includes(paymentFixture.wechatApiV3Key), 'provider bill job response must not leak payment secrets');
|
||||
@@ -4476,6 +4907,8 @@ async function testCommerce() {
|
||||
});
|
||||
assert.equal(crossTenantProviderBillJobsDenied.code, 'TENANT_ADMIN_REQUIRED', 'provider bill jobs must be tenant isolated');
|
||||
|
||||
await cleanupProviderBillJobs(billDate);
|
||||
|
||||
const operationReconItems = await request('/api/commerce/reconciliation/items', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { batchId: reconciliationImport.item.id, matchStatus: 'amount_mismatch' },
|
||||
@@ -7945,12 +8378,48 @@ async function testTenantAdminOps() {
|
||||
assert.equal(tenantThemeAfterPublish.item?.draftTemplateCode, null, 'theme publish should clear draft template');
|
||||
assert.equal(tenantThemeAfterPublish.item?.activePublicAssets?.iconSet, 'focus', 'theme publish should expose active public assets');
|
||||
|
||||
await request('/api/tenant-admin/branding', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
brandName: '集成测试品牌',
|
||||
shortName: '集测题库',
|
||||
theme: { primaryColor: '#0f766e' },
|
||||
publicAssets: { iconSet: 'classic' },
|
||||
},
|
||||
});
|
||||
|
||||
const resolvedTenantTheme = await request('/api/tenant/resolve', {
|
||||
userId: false,
|
||||
tenantId: false,
|
||||
query: { tenantCode: 'master' },
|
||||
});
|
||||
assert.equal(resolvedTenantTheme.branding?.theme?.primaryColor, '#123abc', 'tenant resolve should return published theme tokens to frontend');
|
||||
assert.equal(resolvedTenantTheme.branding?.publicAssets?.iconSet, 'focus', 'tenant resolve should prefer published theme assets over branding fallback assets');
|
||||
|
||||
const themePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
await themePool.query(
|
||||
`
|
||||
update public.tenant_theme_configs
|
||||
set active_theme = '{}'::jsonb,
|
||||
active_public_assets = '{}'::jsonb,
|
||||
status = 'published',
|
||||
published_at = now()
|
||||
where tenant_id = $1
|
||||
`,
|
||||
[MAIN_TENANT_ID],
|
||||
);
|
||||
} finally {
|
||||
await themePool.end();
|
||||
}
|
||||
const resolvedTenantBrandingFallback = await request('/api/tenant/resolve', {
|
||||
userId: false,
|
||||
tenantId: false,
|
||||
query: { tenantCode: 'master' },
|
||||
});
|
||||
assert.equal(resolvedTenantBrandingFallback.branding?.theme?.primaryColor, '#0f766e', 'empty published theme should fall back to tenant branding tokens');
|
||||
assert.equal(resolvedTenantBrandingFallback.branding?.publicAssets?.iconSet, 'classic', 'empty published assets should fall back to tenant branding assets');
|
||||
|
||||
const themeAuditLogs = await request('/api/tenant-admin/audit-logs', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -9139,6 +9608,8 @@ async function testTenantMemberPermissionsAndAudit() {
|
||||
});
|
||||
assert.ok(salesBatch.item?.id, 'sales role should create code batch');
|
||||
|
||||
const salesSessionId = await insertIntegrationAuthSession(MAIN_TENANT_ID, TENANT_SALES_USER_ID, 'tenant-member-disable');
|
||||
|
||||
const salesBrandingDenied = await request('/api/tenant-admin/branding', {
|
||||
userId: TENANT_SALES_USER_ID,
|
||||
method: 'PUT',
|
||||
@@ -9155,6 +9626,10 @@ async function testTenantMemberPermissionsAndAudit() {
|
||||
body: { membershipId: sales.item.id },
|
||||
});
|
||||
assert.equal(disableSales.item?.status, 'disabled', 'tenant admin should disable sales membership');
|
||||
await assertIntegrationAuthSessionRevoked(
|
||||
salesSessionId,
|
||||
'disabling a tenant member must revoke that user\'s tenant sessions in the same operation',
|
||||
);
|
||||
|
||||
const disabledSalesDenied = await request('/api/tenant-admin/code-batches', {
|
||||
userId: TENANT_SALES_USER_ID,
|
||||
@@ -9274,7 +9749,7 @@ async function testTenantClassStudentScopes() {
|
||||
userId: USER_ID,
|
||||
username: 'smoke_student',
|
||||
phone: '13800000000',
|
||||
name: 'Smoke Student',
|
||||
name: 'Cursor Cohort Smoke Student',
|
||||
regionId: ids.region,
|
||||
status: 'active',
|
||||
},
|
||||
@@ -9288,13 +9763,40 @@ async function testTenantClassStudentScopes() {
|
||||
userId: SECOND_STUDENT_USER_ID,
|
||||
username: 'integration_second_student',
|
||||
phone: '13800000016',
|
||||
name: 'Integration Second Student',
|
||||
name: 'Cursor Cohort Integration Student',
|
||||
regionId: ids.region,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(secondStudent.item?.userId, SECOND_STUDENT_USER_ID, 'tenant admin should upsert another student');
|
||||
|
||||
const studentPageOne = await request('/api/tenant-admin/students', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { keyword: 'Cursor Cohort', limit: 1 },
|
||||
});
|
||||
assert.equal(studentPageOne.items?.length, 1, 'student cursor page should honor its limit');
|
||||
assert.equal(studentPageOne.hasMore, true, 'student cursor page should report more matching rows');
|
||||
assert.ok(studentPageOne.nextCursor, 'student cursor page should return an opaque next cursor');
|
||||
|
||||
const studentPageTwo = await request('/api/tenant-admin/students', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { keyword: 'Cursor Cohort', limit: 1, cursor: studentPageOne.nextCursor },
|
||||
});
|
||||
assert.equal(studentPageTwo.items?.length, 1, 'second student cursor page should contain the next row');
|
||||
assert.notEqual(
|
||||
studentPageTwo.items?.[0]?.membershipId,
|
||||
studentPageOne.items?.[0]?.membershipId,
|
||||
'student cursor pages must not repeat the boundary membership',
|
||||
);
|
||||
assert.equal(studentPageTwo.hasMore, false, 'second student cursor page should reach the end of the fixture');
|
||||
|
||||
const invalidStudentCursor = await request('/api/tenant-admin/students', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { cursor: 'not-a-valid-cursor' },
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidStudentCursor.code, 'INVALID_STUDENT_CURSOR', 'student list should reject malformed cursors');
|
||||
|
||||
const teacherAssignment = await request('/api/tenant-admin/classes/members', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
@@ -9509,6 +10011,8 @@ async function testTenantStudentOperations() {
|
||||
});
|
||||
assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list');
|
||||
|
||||
const studentSessionId = await insertIntegrationAuthSession(MAIN_TENANT_ID, bulkStudentUserId, 'tenant-student-disable');
|
||||
|
||||
const disabled = await request('/api/tenant-admin/students/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
@@ -9519,6 +10023,10 @@ async function testTenantStudentOperations() {
|
||||
},
|
||||
});
|
||||
assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership');
|
||||
await assertIntegrationAuthSessionRevoked(
|
||||
studentSessionId,
|
||||
'disabling a student must revoke that student\'s tenant sessions in the same operation',
|
||||
);
|
||||
|
||||
const disabledStudents = await request('/api/tenant-admin/students', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -10620,13 +11128,22 @@ async function testReferralAndCrmGrowth() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let primaryError = null;
|
||||
let destructiveTargetApproved = false;
|
||||
try {
|
||||
await assertLocalIntegrationTarget();
|
||||
destructiveTargetApproved = true;
|
||||
tenantPresentationSnapshot = await captureTenantPresentationSnapshot();
|
||||
await cleanupIntegrationAuthSessions();
|
||||
await cleanupSmsIntegrationRateLimits();
|
||||
await check('production config fail-fast', testProductionConfigFailFast);
|
||||
await startServerIfNeeded();
|
||||
console.log(`[INFO] API integration target: ${apiBase}`);
|
||||
|
||||
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
|
||||
await check('concurrent SMS send reservation', testConcurrentSmsSendReservation);
|
||||
await check('trusted session identity', testTrustedSessionIdentity);
|
||||
await check('auth status enforcement', testAuthStatusEnforcement);
|
||||
await check('phone binding', testPhoneBinding);
|
||||
await check('Supabase JWT identity', testSupabaseJwtIdentity);
|
||||
await check('platform admin permissions', testPlatformAdminPermissions);
|
||||
@@ -10656,9 +11173,27 @@ async function main() {
|
||||
await check('referral and CRM growth', testReferralAndCrmGrowth);
|
||||
|
||||
console.log('API integration tests complete.');
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
stopServer();
|
||||
const cleanupResults = destructiveTargetApproved
|
||||
? await Promise.allSettled([
|
||||
cleanupProviderBillJobs(shanghaiDateKey()),
|
||||
cleanupIntegrationAuthSessions(),
|
||||
cleanupSmsIntegrationRateLimits(),
|
||||
restoreTenantPresentationSnapshot(tenantPresentationSnapshot),
|
||||
])
|
||||
: [];
|
||||
const cleanupFailures = cleanupResults.filter(result => result.status === 'rejected');
|
||||
if (cleanupFailures.length > 0) {
|
||||
primaryError = new AggregateError(
|
||||
[primaryError, ...cleanupFailures.map(result => result.reason)].filter(Boolean),
|
||||
'API integration cleanup failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
|
||||
Reference in New Issue
Block a user