feat: establish production SaaS foundation

This commit is contained in:
Codex
2026-07-12 19:26:57 +08:00
parent 1c2ce38cea
commit 39f7332f33
219 changed files with 20647 additions and 2628 deletions

View File

@@ -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 => {