forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
138
scripts/api-cors-policy-test.js
Normal file
138
scripts/api-cors-policy-test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { CorsPolicy, normalizeCorsOrigin } from '../apps/api/src/core/cors.ts';
|
||||
|
||||
assert.deepEqual(normalizeCorsOrigin('HTTPS://Campus-A.Example.com:443'), {
|
||||
origin: 'https://campus-a.example.com',
|
||||
hostname: 'campus-a.example.com',
|
||||
protocol: 'https:',
|
||||
hasNonDefaultPort: false,
|
||||
});
|
||||
assert.equal(normalizeCorsOrigin('https://campus-a.example.com/path'), null);
|
||||
assert.equal(normalizeCorsOrigin('https://campus-a.example.com,https://evil.example'), null);
|
||||
assert.equal(normalizeCorsOrigin('null'), null);
|
||||
|
||||
let now = 1_000;
|
||||
let lookupCalls = 0;
|
||||
const domainStates = new Map([
|
||||
['active.example.test', true],
|
||||
['inactive.example.test', false],
|
||||
]);
|
||||
const policy = new CorsPolicy({
|
||||
staticOrigins: ['https://platform.example.test', 'http://localhost:5173'],
|
||||
tenantDomainsEnabled: true,
|
||||
positiveCacheTtlMs: 1_000,
|
||||
negativeCacheTtlMs: 200,
|
||||
maxCacheEntries: 2,
|
||||
now: () => now,
|
||||
lookupTenantDomain: async host => {
|
||||
lookupCalls += 1;
|
||||
return domainStates.get(host) === true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await policy.evaluate(''), { allowed: true, allowOrigin: null, reason: 'no-origin' });
|
||||
assert.equal(lookupCalls, 0, 'non-browser requests without Origin must not query tenant domains');
|
||||
assert.deepEqual(await policy.evaluate('HTTPS://PLATFORM.EXAMPLE.TEST:443'), {
|
||||
allowed: true,
|
||||
allowOrigin: 'https://platform.example.test',
|
||||
reason: 'static-origin',
|
||||
});
|
||||
assert.equal(lookupCalls, 0, 'static platform origins must not query tenant domains');
|
||||
assert.equal((await policy.evaluate('http://localhost:5173')).allowed, true);
|
||||
assert.equal((await policy.evaluate('http://active.example.test')).allowed, false, 'dynamic tenant origins must use HTTPS');
|
||||
assert.equal((await policy.evaluate('https://active.example.test:8443')).allowed, false, 'dynamic tenant origins must not use custom ports');
|
||||
assert.equal((await policy.evaluate('https://127.0.0.1')).allowed, false, 'dynamic tenant origins must not allow loopback hosts');
|
||||
|
||||
assert.equal((await policy.evaluate('https://active.example.test')).allowed, true);
|
||||
assert.equal(lookupCalls, 1);
|
||||
assert.equal((await policy.evaluate('https://active.example.test')).allowed, true);
|
||||
assert.equal(lookupCalls, 1, 'active tenant domains must use the positive cache');
|
||||
|
||||
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, false);
|
||||
assert.equal(lookupCalls, 2);
|
||||
domainStates.set('inactive.example.test', true);
|
||||
now += 199;
|
||||
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, false);
|
||||
assert.equal(lookupCalls, 2, 'inactive tenant domains must use the negative cache before expiry');
|
||||
now += 1;
|
||||
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, true);
|
||||
assert.equal(lookupCalls, 3, 'negative cache expiry must refresh the database decision');
|
||||
|
||||
domainStates.set('active.example.test', false);
|
||||
now += 799;
|
||||
assert.equal((await policy.evaluate('https://active.example.test')).allowed, true, 'positive cache must remain stable before expiry');
|
||||
now += 1;
|
||||
assert.equal((await policy.evaluate('https://active.example.test')).allowed, false, 'positive cache expiry must observe a disabled tenant domain');
|
||||
assert.equal(lookupCalls, 4);
|
||||
|
||||
assert.equal((await policy.evaluate('https://unknown.example.test')).allowed, false, 'unknown tenant domains must be rejected');
|
||||
assert.equal(lookupCalls, 5);
|
||||
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, true);
|
||||
assert.equal(lookupCalls, 6, 'bounded cache must evict the least-recently-used domain after reaching its maximum');
|
||||
|
||||
let concurrentCalls = 0;
|
||||
let releaseLookup;
|
||||
const concurrentPolicy = new CorsPolicy({
|
||||
staticOrigins: [],
|
||||
tenantDomainsEnabled: true,
|
||||
positiveCacheTtlMs: 1_000,
|
||||
negativeCacheTtlMs: 200,
|
||||
maxCacheEntries: 100,
|
||||
lookupTenantDomain: async () => {
|
||||
concurrentCalls += 1;
|
||||
await new Promise(resolve => { releaseLookup = resolve; });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const pendingA = concurrentPolicy.evaluate('https://concurrent.example.test');
|
||||
const pendingB = concurrentPolicy.evaluate('https://concurrent.example.test');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(concurrentCalls, 1, 'same-host cache misses must share one database lookup');
|
||||
releaseLookup();
|
||||
assert.equal((await pendingA).allowed, true);
|
||||
assert.equal((await pendingB).allowed, true);
|
||||
|
||||
let failureNow = 10_000;
|
||||
let failureCalls = 0;
|
||||
let databaseHealthy = false;
|
||||
const lookupErrors = [];
|
||||
const failurePolicy = new CorsPolicy({
|
||||
staticOrigins: [],
|
||||
tenantDomainsEnabled: true,
|
||||
positiveCacheTtlMs: 1_000,
|
||||
negativeCacheTtlMs: 200,
|
||||
maxCacheEntries: 100,
|
||||
now: () => failureNow,
|
||||
onLookupError: error => lookupErrors.push(error),
|
||||
lookupTenantDomain: async () => {
|
||||
failureCalls += 1;
|
||||
if (!databaseHealthy) throw new Error('database unavailable');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const failedLookup = await failurePolicy.evaluate('https://recover.example.test');
|
||||
assert.equal(failedLookup.allowed, false, 'tenant domain lookup failures must fail closed');
|
||||
assert.equal(failedLookup.reason, 'tenant-domain-lookup-failed');
|
||||
assert.equal(lookupErrors.length, 1);
|
||||
databaseHealthy = true;
|
||||
failureNow += 199;
|
||||
const cachedFailure = await failurePolicy.evaluate('https://recover.example.test');
|
||||
assert.equal(cachedFailure.allowed, false);
|
||||
assert.equal(cachedFailure.reason, 'tenant-domain-lookup-failed', 'cached lookup failures must retain their failure reason');
|
||||
assert.equal(failureCalls, 1, 'lookup failures must use a short negative cache to avoid database stampedes');
|
||||
failureNow += 1;
|
||||
assert.equal((await failurePolicy.evaluate('https://recover.example.test')).allowed, true);
|
||||
assert.equal(failureCalls, 2, 'a recovered database must be consulted after negative cache expiry');
|
||||
|
||||
const disabledPolicy = new CorsPolicy({
|
||||
staticOrigins: ['https://platform.example.test'],
|
||||
tenantDomainsEnabled: false,
|
||||
positiveCacheTtlMs: 1_000,
|
||||
negativeCacheTtlMs: 200,
|
||||
maxCacheEntries: 100,
|
||||
lookupTenantDomain: async () => true,
|
||||
});
|
||||
assert.equal((await disabledPolicy.evaluate('https://tenant.example.test')).allowed, false);
|
||||
assert.equal((await disabledPolicy.evaluate('https://platform.example.test')).allowed, true);
|
||||
|
||||
console.log('[PASS] dynamic tenant CORS policy, bounded cache and fail-closed contract');
|
||||
25
scripts/api-docker-runtime-contract-test.js
Normal file
25
scripts/api-docker-runtime-contract-test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const dockerfile = fs.readFileSync(path.join(repoRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
|
||||
|
||||
assert.match(
|
||||
dockerfile,
|
||||
/ARG NODE_IMAGE=node:20\.20\.2-alpine3\.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293/,
|
||||
'API image must pin the reviewed multi-architecture Node/Alpine manifest',
|
||||
);
|
||||
assert.match(dockerfile, /FROM deps AS production-deps/);
|
||||
assert.match(dockerfile, /npm prune --omit=dev --workspaces --include-workspace-root/);
|
||||
assert.match(dockerfile, /COPY --from=production-deps --chown=node:node \/app\/node_modules \.\/node_modules/);
|
||||
assert.match(dockerfile, /COPY --from=build --chown=node:node \/app\/apps\/api\/dist \.\/apps\/api\/dist/);
|
||||
assert.match(dockerfile, /\nUSER node\n/);
|
||||
assert.match(dockerfile, /CMD \["node", "apps\/api\/dist\/apps\/api\/src\/server\.js"\]/);
|
||||
assert.doesNotMatch(
|
||||
dockerfile.slice(dockerfile.lastIndexOf(`FROM \${NODE_IMAGE} AS runner`)),
|
||||
/package-lock\.json|package\.json|scripts\/import-pocketbase|COPY --from=deps \/app\/node_modules/,
|
||||
'runtime stage must contain only production dependencies and compiled API files',
|
||||
);
|
||||
|
||||
console.log('[PASS] API Docker runtime least-privilege contract');
|
||||
@@ -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 => {
|
||||
|
||||
141
scripts/api-server-operations-contract-test.js
Normal file
141
scripts/api-server-operations-contract-test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(error => error ? reject(error) : resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitFor(predicate, timeoutMs = 10_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const value = predicate();
|
||||
if (value) {
|
||||
clearInterval(timer);
|
||||
resolve(value);
|
||||
} else if (Date.now() - startedAt > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('timed out waiting for API server operation'));
|
||||
}
|
||||
}, 25);
|
||||
});
|
||||
}
|
||||
|
||||
function rawRequest(port, { method = 'GET', path = '/', headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({ hostname: '127.0.0.1', port, method, path, headers }, response => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', chunk => { body += chunk; });
|
||||
response.on('end', () => resolve({ statusCode: response.statusCode, headers: response.headers, body }));
|
||||
});
|
||||
request.once('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'development',
|
||||
PORT: String(port),
|
||||
API_SHUTDOWN_GRACE_PERIOD_MS: '2000',
|
||||
CORS_ORIGIN: 'https://platform.example.test',
|
||||
CORS_TENANT_DOMAINS_ENABLED: 'false',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', chunk => { output += chunk.toString(); });
|
||||
child.stderr.on('data', chunk => { output += chunk.toString(); });
|
||||
|
||||
try {
|
||||
await waitFor(() => output.includes('"event":"server_listening"'));
|
||||
const providedRequestId = 'operations-contract-123';
|
||||
const response = await fetch(`http://127.0.0.1:${port}/not-found?secret=query-value`, {
|
||||
headers: { 'x-request-id': providedRequestId },
|
||||
});
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(response.headers.get('x-request-id'), providedRequestId);
|
||||
assert.equal(body.requestId, providedRequestId);
|
||||
assert.equal(body.meta.requestId, providedRequestId);
|
||||
await waitFor(() => output.includes('"event":"http_request"'));
|
||||
assert.match(output, /"requestId":"operations-contract-123"/);
|
||||
assert.match(output, /"path":"\/not-found"/);
|
||||
assert.ok(!output.includes('query-value'), 'structured access logs must not record query strings');
|
||||
|
||||
const allowedPreflight = await rawRequest(port, {
|
||||
method: 'OPTIONS',
|
||||
path: '/api/platform-admin/tenants',
|
||||
headers: { origin: 'https://platform.example.test', 'access-control-request-method': 'GET' },
|
||||
});
|
||||
assert.equal(allowedPreflight.statusCode, 204);
|
||||
assert.equal(allowedPreflight.headers['access-control-allow-origin'], 'https://platform.example.test');
|
||||
assert.match(String(allowedPreflight.headers.vary || ''), /origin/i);
|
||||
|
||||
const deniedPreflight = await rawRequest(port, {
|
||||
method: 'OPTIONS',
|
||||
path: '/health',
|
||||
headers: {
|
||||
origin: 'https://unknown.example.test',
|
||||
host: 'platform.example.test',
|
||||
'x-forwarded-host': 'platform.example.test',
|
||||
'x-tenant-code': 'master',
|
||||
'access-control-request-method': 'GET',
|
||||
},
|
||||
});
|
||||
assert.equal(deniedPreflight.statusCode, 403, 'unknown Origin preflight must be explicitly rejected');
|
||||
assert.equal(deniedPreflight.headers['access-control-allow-origin'], undefined);
|
||||
assert.equal(JSON.parse(deniedPreflight.body).code, 'CORS_ORIGIN_DENIED');
|
||||
|
||||
const deniedRequest = await rawRequest(port, {
|
||||
path: '/not-found',
|
||||
headers: {
|
||||
origin: 'https://unknown.example.test',
|
||||
host: 'platform.example.test',
|
||||
'x-forwarded-host': 'platform.example.test',
|
||||
},
|
||||
});
|
||||
assert.equal(deniedRequest.statusCode, 403, 'spoofed Host headers must not bypass Origin validation');
|
||||
assert.equal(JSON.parse(deniedRequest.body).code, 'CORS_ORIGIN_DENIED');
|
||||
|
||||
const duplicateOriginRequest = await rawRequest(port, {
|
||||
path: '/not-found',
|
||||
headers: { origin: ['https://platform.example.test', 'https://unknown.example.test'] },
|
||||
});
|
||||
assert.equal(duplicateOriginRequest.statusCode, 403, 'duplicate Origin headers must be rejected');
|
||||
assert.equal(JSON.parse(duplicateOriginRequest.body).code, 'CORS_ORIGIN_DENIED');
|
||||
|
||||
const originlessHealthPreflight = await rawRequest(port, { method: 'OPTIONS', path: '/health' });
|
||||
assert.equal(originlessHealthPreflight.statusCode, 204, 'originless health checks must not be blocked by CORS');
|
||||
assert.equal(originlessHealthPreflight.headers['access-control-allow-origin'], undefined);
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const exit = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('API server did not exit after SIGTERM')), 7_000);
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
assert.equal(exit.code, 0, `API server should gracefully exit: ${output}`);
|
||||
assert.match(output, /"event":"shutdown_started"/);
|
||||
assert.match(output, /"event":"shutdown_complete"/);
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
|
||||
console.log('[PASS] API operations, fail-closed CORS and graceful shutdown contract');
|
||||
32
scripts/audit-log-capacity-contract-test.js
Normal file
32
scripts/audit-log-capacity-contract-test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const migration = fs.readFileSync(
|
||||
'supabase/migrations/202607120014_audit_log_capacity_indexes.sql',
|
||||
'utf8',
|
||||
);
|
||||
const tenantRoutes = fs.readFileSync('apps/api/src/features/tenant-admin/routes.ts', 'utf8');
|
||||
const platformRoutes = fs.readFileSync('apps/api/src/features/platform-admin/routes.ts', 'utf8');
|
||||
const alertWorker = fs.readFileSync('apps/worker/src/jobs/platform-audit-alerts.ts', 'utf8');
|
||||
|
||||
for (const indexName of [
|
||||
'idx_audit_logs_created',
|
||||
'idx_audit_logs_tenant_created',
|
||||
'idx_audit_logs_tenant_actor_created',
|
||||
'idx_audit_logs_tenant_target_created',
|
||||
'idx_audit_logs_platform_created',
|
||||
]) {
|
||||
assert.match(migration, new RegExp(`create index if not exists ${indexName}\\b`, 'i'));
|
||||
}
|
||||
|
||||
assert.match(migration, /\(tenant_id, created_at desc, id desc\)/i);
|
||||
assert.match(migration, /\(tenant_id, actor_user_id, created_at desc, id desc\)/i);
|
||||
assert.match(migration, /\(tenant_id, target_type, created_at desc, id desc\)/i);
|
||||
assert.match(migration, /where action like 'platform\.%'/i);
|
||||
|
||||
assert.match(tenantRoutes, /const filters = \['al\.tenant_id = \$1'\]/i);
|
||||
assert.match(tenantRoutes, /from public\.audit_logs al[\s\S]+order by al\.created_at desc/i);
|
||||
assert.match(platformRoutes, /from public\.audit_logs al[\s\S]+order by al\.created_at desc/i);
|
||||
assert.match(alertWorker, /from public\.audit_logs al[\s\S]+al\.action like 'platform\.%'[\s\S]+al\.created_at >=/i);
|
||||
|
||||
console.log('[PASS] audit log capacity index contract');
|
||||
131
scripts/auth-context-platform-admin-test.js
Normal file
131
scripts/auth-context-platform-admin-test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
process.env.NODE_ENV = 'development';
|
||||
const { findUserBySessionToken, findUserByVerifiedSupabasePayload } = await import('../apps/api/src/core/auth-context.ts');
|
||||
|
||||
const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const TENANT_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const OTHER_TENANT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const platformSession = {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
username: 'platform-admin',
|
||||
phone: null,
|
||||
name: 'Platform Admin',
|
||||
avatarUrl: null,
|
||||
primaryRole: 'platform_admin',
|
||||
createdAt: new Date(0).toISOString(),
|
||||
tenantId: null,
|
||||
sessionId: AUTH_USER_ID,
|
||||
sessionExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
authSource: 'supabase_jwt',
|
||||
authUserId: AUTH_USER_ID,
|
||||
platformPermissions: { '*': true },
|
||||
};
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const tenantSession = { ...platformSession, primaryRole: 'student', tenantId: TENANT_ID, authSource: 'app_session' };
|
||||
const session = await findUserBySessionToken('tk_test_session', async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return tenantSession;
|
||||
});
|
||||
assert.equal(session, tenantSession);
|
||||
assert.match(calls[0].sql, /join public\.tenants t on t\.id = s\.tenant_id/);
|
||||
assert.match(calls[0].sql, /t\.status = 'active'/);
|
||||
assert.match(calls[0].sql, /tm\.status = 'active'/);
|
||||
assert.match(calls[0].sql, /u\.status = 'active'/);
|
||||
}
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const session = await findUserByVerifiedSupabasePayload({
|
||||
sub: AUTH_USER_ID,
|
||||
role: 'authenticated',
|
||||
app_metadata: { provider: 'phone' },
|
||||
}, '', async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return platformSession;
|
||||
});
|
||||
assert.equal(session, platformSession, 'standard Supabase role=authenticated must not downgrade a database platform admin');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].sql.includes('tenant_memberships'), false, 'global platform lookup must not require tenant membership');
|
||||
}
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const session = await findUserByVerifiedSupabasePayload({
|
||||
sub: AUTH_USER_ID,
|
||||
role: 'authenticated',
|
||||
}, OTHER_TENANT_ID, async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return platformSession;
|
||||
});
|
||||
assert.equal(session?.primaryRole, 'platform_admin');
|
||||
assert.equal(session?.tenantId, null, 'global platform identity must not acquire tenant membership from request context');
|
||||
assert.equal(calls.length, 1, 'platform admin should resolve before tenant membership lookup');
|
||||
}
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const session = await findUserByVerifiedSupabasePayload({
|
||||
sub: AUTH_USER_ID,
|
||||
role: 'service_role',
|
||||
app_role: 'platform_admin',
|
||||
app_metadata: { app_role: 'platform_admin' },
|
||||
}, '', async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return null;
|
||||
});
|
||||
assert.equal(session, null, 'JWT role claims alone must never create platform authority');
|
||||
assert.equal(calls.length, 1, 'JWT-only elevation must stop after authoritative platform lookup fails');
|
||||
}
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const studentSession = { ...platformSession, primaryRole: 'student', tenantId: TENANT_ID, platformPermissions: {} };
|
||||
const session = await findUserByVerifiedSupabasePayload({
|
||||
sub: AUTH_USER_ID,
|
||||
role: 'authenticated',
|
||||
app_metadata: { app_role: 'platform_admin' },
|
||||
}, TENANT_ID, async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return calls.length === 1 ? null : studentSession;
|
||||
});
|
||||
assert.equal(session?.primaryRole, 'student', 'a malicious app_role claim must not elevate a tenant member');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[1].sql, /join public\.tenants t on t\.id = tm\.tenant_id/);
|
||||
assert.match(calls[1].sql, /t\.status = 'active'/);
|
||||
assert.match(calls[1].sql, /tm\.status = 'active'/);
|
||||
assert.match(calls[1].sql, /u\.status = 'active'/);
|
||||
}
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const session = await findUserByVerifiedSupabasePayload({
|
||||
sub: AUTH_USER_ID,
|
||||
role: 'authenticated',
|
||||
app_metadata: { tenant_id: TENANT_ID },
|
||||
}, OTHER_TENANT_ID, async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return platformSession;
|
||||
});
|
||||
assert.equal(session?.primaryRole, 'platform_admin', 'database platform admin may select a tenant beyond its JWT default claim');
|
||||
assert.equal(session?.tenantId, null, 'JWT tenant defaults must not become implicit platform membership');
|
||||
assert.equal(calls.length, 1);
|
||||
}
|
||||
|
||||
{
|
||||
let queryCount = 0;
|
||||
const session = await findUserByVerifiedSupabasePayload({
|
||||
sub: AUTH_USER_ID,
|
||||
role: 'authenticated',
|
||||
app_metadata: { tenant_id: TENANT_ID },
|
||||
}, OTHER_TENANT_ID, async () => {
|
||||
queryCount += 1;
|
||||
return null;
|
||||
});
|
||||
assert.equal(session, null, 'signed tenant claim must not be overwritten by request context');
|
||||
assert.equal(queryCount, 1, 'tenant mismatch may perform only the authoritative global platform lookup');
|
||||
}
|
||||
|
||||
console.log('[PASS] Supabase JWT platform authority contract');
|
||||
@@ -1,11 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import pg from 'pg';
|
||||
import { autoGrantBadges } from '../apps/api/src/features/profile/badges.ts';
|
||||
import {
|
||||
assertDestructiveTestDatabase,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101';
|
||||
const BADGE_ID = '00000000-0000-0000-0000-00000000b881';
|
||||
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 8 });
|
||||
|
||||
@@ -76,6 +81,12 @@ async function grantOnce(index) {
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await assertDestructiveTestDatabase({
|
||||
client: pool,
|
||||
databaseUrl: DATABASE_URL,
|
||||
confirmation: destructiveTestConfirmation,
|
||||
operation: 'auto badge concurrency test',
|
||||
});
|
||||
await resetFixture();
|
||||
const results = await Promise.all(Array.from({ length: 20 }, (_, index) => grantOnce(index)));
|
||||
const grantedRows = results.flat().filter(item => item.badgeId === BADGE_ID);
|
||||
|
||||
180
scripts/backend-runtime-role-contract-test.js
Normal file
180
scripts/backend-runtime-role-contract-test.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const migration = fs.readFileSync(
|
||||
path.join(repoRoot, 'supabase', 'migrations', '202607120013_backend_runtime_roles.sql'),
|
||||
'utf8',
|
||||
);
|
||||
const authBoundaryMigration = fs.readFileSync(
|
||||
path.join(repoRoot, 'supabase', 'migrations', '202607120018_auth_user_reference_boundary.sql'),
|
||||
'utf8',
|
||||
);
|
||||
const migrationHistoryBoundaryMigration = fs.readFileSync(
|
||||
path.join(repoRoot, 'supabase', 'migrations', '202607120019_production_migration_history_boundary.sql'),
|
||||
'utf8',
|
||||
);
|
||||
const safetyMigration = fs.readFileSync(
|
||||
path.join(repoRoot, 'supabase', 'migrations', '202607120001_destructive_test_environment_safety.sql'),
|
||||
'utf8',
|
||||
);
|
||||
const readiness = fs.readFileSync(
|
||||
path.join(repoRoot, 'scripts', 'production-readiness-check.js'),
|
||||
'utf8',
|
||||
);
|
||||
const apiEnv = fs.readFileSync(
|
||||
path.join(repoRoot, 'scripts', 'deploy', 'env', 'api.env.example'),
|
||||
'utf8',
|
||||
);
|
||||
const workerEnv = fs.readFileSync(
|
||||
path.join(repoRoot, 'scripts', 'deploy', 'env', 'worker.env.example'),
|
||||
'utf8',
|
||||
);
|
||||
const destructiveGuard = fs.readFileSync(
|
||||
path.join(repoRoot, 'scripts', 'lib', 'destructive-test-database-guard.js'),
|
||||
'utf8',
|
||||
);
|
||||
const platformAdminRoutes = fs.readFileSync(
|
||||
path.join(repoRoot, 'apps', 'api', 'src', 'features', 'platform-admin', 'routes.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const platformAdminBootstrap = fs.readFileSync(
|
||||
path.join(repoRoot, 'scripts', 'bootstrap-platform-admin.js'),
|
||||
'utf8',
|
||||
);
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
|
||||
for (const role of ['tiku_api', 'tiku_worker']) {
|
||||
assert.match(destructiveGuard, new RegExp(`['"]${role}['"]`));
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/create role tiku_(?:api|worker)|alter role tiku_(?:api|worker)/i,
|
||||
'normal Supabase migrations must not require superuser-only cluster role changes',
|
||||
);
|
||||
assert.match(migration, /not role_state\.rolbypassrls/i);
|
||||
assert.match(migration, /role_state\.has_parent_roles/i);
|
||||
|
||||
assert.match(migration, /search_path=pg_catalog, public, extensions/i);
|
||||
assert.match(migration, /revoke create on schema public, app, app_private, extensions from public/i);
|
||||
assert.match(migration, /revoke all privileges on schema public, app, app_private, extensions from tiku_api, tiku_worker/i);
|
||||
assert.match(migration, /grant usage on schema public, app_private, extensions to tiku_api, tiku_worker/i);
|
||||
assert.match(migration, /grant usage on schema app to tiku_api/i);
|
||||
assert.doesNotMatch(migration, /grant usage on schema app to tiku_worker/i);
|
||||
|
||||
assert.match(
|
||||
migration,
|
||||
/grant select, insert, update, delete[\s\S]*on all tables in schema public[\s\S]*to tiku_api, tiku_worker/i,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/grant select[\s\S]*on all tables in schema app_private[\s\S]*to tiku_api, tiku_worker/i,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/grant insert, update[\s\S]*on app_private\.auth_sessions,[\s\S]*app_private\.tenant_secrets,[\s\S]*app_private\.platform_secrets[\s\S]*to tiku_api/i,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/grant insert, update, delete[\s\S]*on app_private\.sms_send_rate_limits[\s\S]*to tiku_api/i,
|
||||
);
|
||||
assert.doesNotMatch(migration, /grant[^;]*truncate[^;]*to tiku_api|grant[^;]*truncate[^;]*to tiku_worker/i);
|
||||
|
||||
assert.match(migration, /revoke execute on all functions in schema app from public/i);
|
||||
assert.match(
|
||||
migration,
|
||||
/alter table %I\.%I alter column %I set default pg_catalog\.gen_random_uuid\(\)/i,
|
||||
'UUID defaults must be independent of the pgcrypto extension schema',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/grant execute on function (?:public|extensions)\.gen_random_uuid\(\)/i,
|
||||
'runtime roles must rely on the PostgreSQL core UUID function instead of an extension wrapper',
|
||||
);
|
||||
assert.match(migration, /grant execute on function app\.public_question_bank_grant_allows[\s\S]*to tiku_api/i);
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/grant execute[\s\S]*on all functions in schema (?:public|app|app_private)[\s\S]*to tiku_api/i,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i,
|
||||
'default ACL loops must skip Supabase-owned roles the migration user cannot SET ROLE into',
|
||||
);
|
||||
|
||||
assert.match(migration, /from pg_auth_members membership/i);
|
||||
assert.match(migration, /tiku_api\/tiku_worker must not own database objects/i);
|
||||
assert.doesNotMatch(migration, /password\s+['"]/i, 'role passwords must be provisioned outside migrations');
|
||||
|
||||
assert.match(authBoundaryMigration, /create or replace function app\.auth_user_exists\(target_user_id uuid\)/i);
|
||||
assert.match(authBoundaryMigration, /security definer[\s\S]*set search_path = ''/i);
|
||||
assert.match(authBoundaryMigration, /from auth\.users auth_user[\s\S]*auth_user\.id = target_user_id/i);
|
||||
assert.match(
|
||||
authBoundaryMigration,
|
||||
/revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
|
||||
);
|
||||
assert.match(authBoundaryMigration, /grant execute on function app\.auth_user_exists\(uuid\) to tiku_api/i);
|
||||
assert.doesNotMatch(authBoundaryMigration, /grant[^;]*to tiku_worker/i);
|
||||
assert.match(
|
||||
migrationHistoryBoundaryMigration,
|
||||
/create or replace function app\.production_migration_history\(expected_version text\)/i,
|
||||
);
|
||||
assert.match(
|
||||
migrationHistoryBoundaryMigration,
|
||||
/security definer[\s\S]*set search_path = ''/i,
|
||||
);
|
||||
assert.match(
|
||||
migrationHistoryBoundaryMigration,
|
||||
/from supabase_migrations\.schema_migrations/i,
|
||||
);
|
||||
assert.match(
|
||||
migrationHistoryBoundaryMigration,
|
||||
/revoke all on function app\.production_migration_history\(text\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
|
||||
);
|
||||
assert.match(
|
||||
migrationHistoryBoundaryMigration,
|
||||
/grant execute on function app\.production_migration_history\(text\) to tiku_api/i,
|
||||
);
|
||||
assert.doesNotMatch(migrationHistoryBoundaryMigration, /grant[^;]*to tiku_worker/i);
|
||||
for (const [label, source] of [
|
||||
['platform staff API', platformAdminRoutes],
|
||||
['platform admin bootstrap CLI', platformAdminBootstrap],
|
||||
]) {
|
||||
assert.match(source, /app\.auth_user_exists\(\$1::uuid\)/, `${label} must use the boolean Auth boundary`);
|
||||
assert.doesNotMatch(source, /\bfrom\s+auth\.users\b/i, `${label} must not read auth.users directly`);
|
||||
assert.doesNotMatch(source, /\bjoin\s+auth\.users\b/i, `${label} must not join auth.users directly`);
|
||||
}
|
||||
|
||||
const retiredSharedRole = ['tiku', 'app'].join('_');
|
||||
assert.equal(
|
||||
safetyMigration.includes(retiredSharedRole),
|
||||
false,
|
||||
'safety migration must not retain the retired shared runtime role',
|
||||
);
|
||||
assert.match(apiEnv, /DATABASE_URL=postgresql:\/\/tiku_api:/);
|
||||
assert.match(apiEnv, /^DB_EXPECTED_RUNTIME_ROLE=tiku_api$/m);
|
||||
assert.match(workerEnv, /DATABASE_URL=postgresql:\/\/tiku_worker:/);
|
||||
assert.match(workerEnv, /^DB_EXPECTED_RUNTIME_ROLE=tiku_worker$/m);
|
||||
|
||||
for (const gateId of [
|
||||
'db.runtime_role.identity',
|
||||
'db.runtime_role.attributes',
|
||||
'db.runtime_role.schema_acl',
|
||||
'db.runtime_role.table_acl',
|
||||
'db.runtime_role.function_acl',
|
||||
'db.runtime_role.auth_acl',
|
||||
'db.extensions.isolation',
|
||||
'db.runtime_role.ownership',
|
||||
'db.runtime_role.ddl_denied',
|
||||
'db.migrations.current',
|
||||
]) {
|
||||
assert.ok(readiness.includes(gateId), `readiness must enforce ${gateId}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
packageJson.scripts?.['test:readiness']?.includes('backend-runtime-role-contract-test.js'),
|
||||
'the production readiness contract suite must run the backend runtime role test',
|
||||
);
|
||||
|
||||
console.log('[PASS] backend runtime role least-privilege contract');
|
||||
68
scripts/bootstrap-backend-runtime-roles-test.js
vendored
Normal file
68
scripts/bootstrap-backend-runtime-roles-test.js
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
parseBackendRuntimeRoleBootstrapOptions,
|
||||
} from './bootstrap-backend-runtime-roles.js';
|
||||
|
||||
const sql = fs.readFileSync('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql', 'utf8');
|
||||
const migration = fs.readFileSync('supabase/migrations/202607120013_backend_runtime_roles.sql', 'utf8');
|
||||
|
||||
assert.deepEqual(
|
||||
parseBackendRuntimeRoleBootstrapOptions([], {}),
|
||||
{ apply: false, adminUrl: '', confirmation: '', json: false },
|
||||
);
|
||||
assert.throws(
|
||||
() => parseBackendRuntimeRoleBootstrapOptions(['--apply'], {}),
|
||||
/DATABASE_ADMIN_URL is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseBackendRuntimeRoleBootstrapOptions(['--apply'], {
|
||||
DATABASE_ADMIN_URL: ['postgresql:', '//admin:secret@db.test/postgres'].join(''),
|
||||
}),
|
||||
/BOOTSTRAP_BACKEND_RUNTIME_ROLES/,
|
||||
);
|
||||
assert.match(sql, /alter role tiku_api[\s\S]*bypassrls/i);
|
||||
assert.match(sql, /alter role tiku_worker[\s\S]*bypassrls/i);
|
||||
for (const extensionName of ['pgcrypto', 'citext', 'ltree', 'pg_trgm']) {
|
||||
assert.ok(sql.includes(`'${extensionName}'::name`), `${extensionName} must be managed by the privileged bootstrap`);
|
||||
}
|
||||
assert.match(
|
||||
sql,
|
||||
/if not found then[\s\S]*create extension %I with schema extensions/i,
|
||||
'privileged bootstrap must install extensions before normal migrations can create them with unsafe defaults',
|
||||
);
|
||||
assert.match(sql, /alter extension %I set schema extensions/i);
|
||||
assert.match(sql, /grant usage on schema extensions to tiku_api, tiku_worker/i);
|
||||
assert.match(sql, /search_path = pg_catalog, public, extensions/i);
|
||||
assert.match(sql, /pg_auth_members[\s\S]*revoke %I from %I/i);
|
||||
assert.match(
|
||||
sql,
|
||||
/revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
|
||||
);
|
||||
assert.match(
|
||||
sql,
|
||||
/revoke execute on all functions in schema extensions[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
|
||||
);
|
||||
for (const trustedRole of [
|
||||
'postgres',
|
||||
'service_role',
|
||||
'dashboard_user',
|
||||
'supabase_auth_admin',
|
||||
'supabase_storage_admin',
|
||||
'supabase_realtime_admin',
|
||||
'supabase_functions_admin',
|
||||
]) {
|
||||
assert.ok(sql.includes(`'${trustedRole}'::name`), `${trustedRole} must retain extension execution when present`);
|
||||
}
|
||||
assert.match(
|
||||
sql,
|
||||
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i,
|
||||
);
|
||||
assert.match(sql, /extension\.extname in \('citext', 'ltree', 'pg_trgm'\)[\s\S]*grant execute on function %s to tiku_api, tiku_worker/i);
|
||||
assert.doesNotMatch(sql, /password\s+['"]/i, 'bootstrap must preserve externally managed passwords');
|
||||
|
||||
assert.doesNotMatch(migration, /create role tiku_api|alter role tiku_api/i);
|
||||
assert.match(migration, /Runtime role % is missing[\s\S]*bootstrap-backend-runtime-roles\.js/i);
|
||||
assert.match(migration, /not role_state\.rolbypassrls/i);
|
||||
|
||||
console.log('[PASS] privileged backend runtime role bootstrap contract');
|
||||
251
scripts/bootstrap-backend-runtime-roles.js
vendored
Normal file
251
scripts/bootstrap-backend-runtime-roles.js
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import pg from 'pg';
|
||||
import { describeDatabaseTarget } from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const { Client } = pg;
|
||||
const CONFIRMATION = 'BOOTSTRAP_BACKEND_RUNTIME_ROLES';
|
||||
const sqlPath = fileURLToPath(new URL('./deploy/sql/bootstrap-backend-runtime-roles.sql', import.meta.url));
|
||||
|
||||
function argumentValue(argv, name) {
|
||||
const index = argv.indexOf(name);
|
||||
if (index >= 0) return String(argv[index + 1] || '').trim();
|
||||
const prefix = `${name}=`;
|
||||
const item = argv.find(value => value.startsWith(prefix));
|
||||
return item ? item.slice(prefix.length).trim() : '';
|
||||
}
|
||||
|
||||
export function parseBackendRuntimeRoleBootstrapOptions(
|
||||
argv = process.argv.slice(2),
|
||||
env = process.env,
|
||||
) {
|
||||
const apply = argv.includes('--apply');
|
||||
const adminUrl = String(env.DATABASE_ADMIN_URL || '').trim();
|
||||
const confirmation = argumentValue(argv, '--confirm');
|
||||
if (apply && !adminUrl) throw new Error('DATABASE_ADMIN_URL is required with --apply');
|
||||
if (apply && confirmation !== CONFIRMATION) {
|
||||
throw new Error(`--confirm=${CONFIRMATION} is required with --apply`);
|
||||
}
|
||||
return { apply, adminUrl, confirmation, json: argv.includes('--json') };
|
||||
}
|
||||
|
||||
function roleIsSafe(row) {
|
||||
const config = Array.isArray(row?.rolconfig) ? row.rolconfig.map(String) : [];
|
||||
return row
|
||||
&& row.rolcanlogin === true
|
||||
&& row.rolsuper === false
|
||||
&& row.rolinherit === false
|
||||
&& row.rolcreatedb === false
|
||||
&& row.rolcreaterole === false
|
||||
&& row.rolreplication === false
|
||||
&& row.rolbypassrls === true
|
||||
&& row.hasParentRoles === false
|
||||
&& config.includes('search_path=pg_catalog, public, extensions');
|
||||
}
|
||||
|
||||
async function loadRoleState(client) {
|
||||
const result = await client.query(`
|
||||
select role_row.rolname,
|
||||
role_row.rolcanlogin,
|
||||
role_row.rolsuper,
|
||||
role_row.rolinherit,
|
||||
role_row.rolcreatedb,
|
||||
role_row.rolcreaterole,
|
||||
role_row.rolreplication,
|
||||
role_row.rolbypassrls,
|
||||
role_row.rolconfig,
|
||||
exists (
|
||||
select 1 from pg_auth_members membership
|
||||
where membership.member = role_row.oid
|
||||
) as "hasParentRoles"
|
||||
from pg_roles role_row
|
||||
where role_row.rolname = any(array['tiku_api', 'tiku_worker']::name[])
|
||||
order by role_row.rolname
|
||||
`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function loadPublicFunctionExecutionState(client) {
|
||||
const result = await client.query(`
|
||||
select requested_role.role_name,
|
||||
role_row.oid is not null as role_exists,
|
||||
coalesce((
|
||||
select count(*)::integer
|
||||
from pg_proc function_row
|
||||
join pg_namespace namespace on namespace.oid = function_row.pronamespace
|
||||
where namespace.nspname = 'public'
|
||||
and role_row.oid is not null
|
||||
and has_function_privilege(role_row.oid, function_row.oid, 'EXECUTE')
|
||||
), 0)::integer as executable_function_count
|
||||
from unnest(array['anon', 'authenticated', 'tiku_api', 'tiku_worker']::name[])
|
||||
as requested_role(role_name)
|
||||
left join pg_roles role_row on role_row.rolname = requested_role.role_name
|
||||
order by requested_role.role_name
|
||||
`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function loadExtensionState(client) {
|
||||
const result = await client.query(`
|
||||
select extension.extname,
|
||||
namespace.nspname as schema_name,
|
||||
count(procedure_row.oid)::integer as function_count,
|
||||
count(procedure_row.oid) filter (
|
||||
where has_function_privilege('anon', procedure_row.oid, 'EXECUTE')
|
||||
)::integer as anon_execute_count,
|
||||
count(procedure_row.oid) filter (
|
||||
where has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE')
|
||||
)::integer as authenticated_execute_count,
|
||||
count(procedure_row.oid) filter (
|
||||
where has_function_privilege('tiku_api', procedure_row.oid, 'EXECUTE')
|
||||
)::integer as api_execute_count,
|
||||
count(procedure_row.oid) filter (
|
||||
where has_function_privilege('tiku_worker', procedure_row.oid, 'EXECUTE')
|
||||
)::integer as worker_execute_count
|
||||
from pg_extension extension
|
||||
join pg_namespace namespace on namespace.oid = extension.extnamespace
|
||||
left join pg_depend dependency
|
||||
on dependency.refclassid = 'pg_extension'::regclass
|
||||
and dependency.refobjid = extension.oid
|
||||
and dependency.classid = 'pg_proc'::regclass
|
||||
and dependency.deptype = 'e'
|
||||
left join pg_proc procedure_row on procedure_row.oid = dependency.objid
|
||||
where extension.extname = any(array['pgcrypto', 'citext', 'ltree', 'pg_trgm']::name[])
|
||||
group by extension.extname, namespace.nspname
|
||||
order by extension.extname
|
||||
`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function bootstrapBackendRuntimeRoles(options) {
|
||||
if (!options.apply) {
|
||||
return {
|
||||
status: 'plan',
|
||||
apply: false,
|
||||
confirmation: CONFIRMATION,
|
||||
sqlPath: path.relative(process.cwd(), sqlPath),
|
||||
changes: [
|
||||
'Create tiku_api and tiku_worker if missing without assigning passwords',
|
||||
'Enforce LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS',
|
||||
'Move required extensions out of public and set search_path=pg_catalog,public,extensions',
|
||||
'Close client extension RPC execution while preserving backend citext/ltree operations',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const target = describeDatabaseTarget(options.adminUrl);
|
||||
const client = new Client({
|
||||
connectionString: options.adminUrl,
|
||||
application_name: 'tiku-runtime-role-bootstrap',
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const identityResult = await client.query(`
|
||||
select current_user,
|
||||
current_setting('server_version_num')::integer as server_version_num,
|
||||
rolsuper
|
||||
from pg_roles
|
||||
where rolname = current_user
|
||||
`);
|
||||
const identity = identityResult.rows[0];
|
||||
if (!identity?.rolsuper) {
|
||||
throw new Error(`DATABASE_ADMIN_URL must connect as a PostgreSQL superuser; ${identity?.current_user || 'current role'} is not superuser`);
|
||||
}
|
||||
if (Number(identity.server_version_num) < 130000) {
|
||||
throw new Error('PostgreSQL 13 or newer is required');
|
||||
}
|
||||
|
||||
const sql = await fs.readFile(sqlPath, 'utf8');
|
||||
await client.query('begin');
|
||||
try {
|
||||
await client.query(sql);
|
||||
await client.query('commit');
|
||||
} catch (error) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const roles = await loadRoleState(client);
|
||||
if (roles.length !== 2 || roles.some(row => !roleIsSafe(row))) {
|
||||
throw new Error('Runtime role bootstrap verification failed');
|
||||
}
|
||||
const publicFunctionExecution = await loadPublicFunctionExecutionState(client);
|
||||
if (
|
||||
publicFunctionExecution.length !== 4
|
||||
|| publicFunctionExecution.some(row => !row.role_exists || Number(row.executable_function_count) !== 0)
|
||||
) {
|
||||
throw new Error('Public extension function execution bootstrap verification failed');
|
||||
}
|
||||
const extensions = await loadExtensionState(client);
|
||||
const expectedExtensions = new Set(['pgcrypto', 'citext', 'ltree', 'pg_trgm']);
|
||||
if (
|
||||
extensions.length !== expectedExtensions.size
|
||||
|| extensions.some(row => !expectedExtensions.has(row.extname) || row.schema_name !== 'extensions')
|
||||
) {
|
||||
throw new Error('Required extension schema bootstrap verification failed');
|
||||
}
|
||||
for (const row of extensions) {
|
||||
const functionCount = Number(row.function_count);
|
||||
const backendExecuteCount = row.extname === 'pgcrypto' ? 0 : functionCount;
|
||||
if (
|
||||
functionCount <= 0
|
||||
|| Number(row.anon_execute_count) !== 0
|
||||
|| Number(row.authenticated_execute_count) !== 0
|
||||
|| Number(row.api_execute_count) !== backendExecuteCount
|
||||
|| Number(row.worker_execute_count) !== backendExecuteCount
|
||||
) {
|
||||
throw new Error(`Extension function ACL bootstrap verification failed for ${row.extname}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'pass',
|
||||
apply: true,
|
||||
target,
|
||||
administrator: identity.current_user,
|
||||
roles: roles.map(row => ({
|
||||
name: row.rolname,
|
||||
login: row.rolcanlogin,
|
||||
bypassRls: row.rolbypassrls,
|
||||
noInherit: row.rolinherit === false,
|
||||
hasParentRoles: row.hasParentRoles,
|
||||
searchPath: row.rolconfig,
|
||||
})),
|
||||
publicFunctionExecution: publicFunctionExecution.map(row => ({
|
||||
role: row.role_name,
|
||||
executableFunctionCount: Number(row.executable_function_count),
|
||||
})),
|
||||
extensions: extensions.map(row => ({
|
||||
name: row.extname,
|
||||
schema: row.schema_name,
|
||||
functionCount: Number(row.function_count),
|
||||
})),
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options;
|
||||
try {
|
||||
options = parseBackendRuntimeRoleBootstrapOptions();
|
||||
const result = await bootstrapBackendRuntimeRoles(options);
|
||||
if (options.json) console.log(JSON.stringify(result, null, 2));
|
||||
else if (result.status === 'plan') {
|
||||
console.log('Backend runtime role bootstrap plan');
|
||||
result.changes.forEach(item => console.log(`- ${item}`));
|
||||
console.log(`Apply with --apply --confirm=${CONFIRMATION} and DATABASE_ADMIN_URL.`);
|
||||
} else {
|
||||
console.log(`Backend runtime role bootstrap complete for ${result.target.host}:${result.target.port}/${result.target.database}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) };
|
||||
if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2));
|
||||
else console.error(failure.error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();
|
||||
243
scripts/bootstrap-platform-admin-test.js
vendored
Normal file
243
scripts/bootstrap-platform-admin-test.js
vendored
Normal file
@@ -0,0 +1,243 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
APPLY_CONFIRMATION,
|
||||
BOOTSTRAP_LOCK_KEY,
|
||||
bootstrapPlatformAdmin,
|
||||
buildConfig,
|
||||
helpText,
|
||||
publicResult,
|
||||
sanitizeErrorMessage,
|
||||
} from './bootstrap-platform-admin.js';
|
||||
|
||||
const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const PLATFORM_USER_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const LEGACY_USER_ID = '33333333-3333-4333-8333-333333333333';
|
||||
|
||||
function result(rows = []) {
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
|
||||
function mockPool(responses) {
|
||||
const queries = [];
|
||||
const client = {
|
||||
async query(sql, params = []) {
|
||||
const normalized = String(sql).replace(/\s+/g, ' ').trim();
|
||||
queries.push({ sql: normalized, params });
|
||||
if (normalized === 'begin' || normalized === 'commit' || normalized === 'rollback') return result();
|
||||
const response = responses.shift();
|
||||
if (response instanceof Error) throw response;
|
||||
if (!response) throw new Error(`Unexpected query: ${normalized}`);
|
||||
return response;
|
||||
},
|
||||
release() {},
|
||||
};
|
||||
return {
|
||||
queries,
|
||||
pool: { async connect() { return client; } },
|
||||
assertExhausted() { assert.equal(responses.length, 0, 'all expected database queries should run'); },
|
||||
};
|
||||
}
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
databaseUrl: 'test-database-url',
|
||||
authUserId: AUTH_USER_ID,
|
||||
apply: false,
|
||||
confirmation: APPLY_CONFIRMATION,
|
||||
username: 'owner.admin',
|
||||
email: 'owner@example.test',
|
||||
phone: '13800001234',
|
||||
name: 'Owner Admin',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = buildConfig({
|
||||
DATABASE_URL: 'test-database-url',
|
||||
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID,
|
||||
BOOTSTRAP_PLATFORM_ADMIN_USERNAME: 'owner.admin',
|
||||
BOOTSTRAP_PLATFORM_ADMIN_NAME: 'Owner Admin',
|
||||
}, []);
|
||||
assert.equal(parsed.apply, false, 'bootstrap should default to dry-run');
|
||||
assert.throws(
|
||||
() => buildConfig({
|
||||
DATABASE_URL: 'test-database-url',
|
||||
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID,
|
||||
}, ['--apply']),
|
||||
new RegExp(APPLY_CONFIRMATION),
|
||||
'apply must require the exact confirmation phrase',
|
||||
);
|
||||
assert.equal(
|
||||
buildConfig({
|
||||
DATABASE_URL: 'test-database-url',
|
||||
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID,
|
||||
}, ['--apply', '--confirm', APPLY_CONFIRMATION]).apply,
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => bootstrapPlatformAdmin(config({ apply: true, confirmation: '' }), { pool: mockPool([]).pool }),
|
||||
new RegExp(APPLY_CONFIRMATION),
|
||||
'direct callers must not bypass the apply confirmation gate',
|
||||
);
|
||||
assert.match(helpText(), /active, Auth-bound platform admin exists/);
|
||||
assert.match(helpText(), /exactly one unbound legacy platform_admin/);
|
||||
const sensitiveDatabaseUrl = ['postgresql:', '', 'bootstrap_user:bootstrap_password@db.internal:5432/tiku'].join('/');
|
||||
const safeError = sanitizeErrorMessage(
|
||||
new Error(`connection failed for ${sensitiveDatabaseUrl}`),
|
||||
sensitiveDatabaseUrl,
|
||||
);
|
||||
assert.equal(safeError.includes(sensitiveDatabaseUrl), false, 'failure output must redact the configured database URL');
|
||||
assert.equal(safeError.includes('bootstrap_password'), false, 'failure output must not expose database credentials');
|
||||
|
||||
{
|
||||
const mock = mockPool([
|
||||
result(),
|
||||
result([{ exists: true }]),
|
||||
result(),
|
||||
result(),
|
||||
result([{ id: LEGACY_USER_ID, username: 'legacy.admin', email: null, phone: null, name: 'Legacy Admin' }]),
|
||||
]);
|
||||
const dryRun = await bootstrapPlatformAdmin(config(), { pool: mock.pool });
|
||||
mock.assertExhausted();
|
||||
assert.equal(dryRun.dryRun, true);
|
||||
assert.equal(dryRun.action, 'bind_legacy');
|
||||
assert.equal(dryRun.platformUserId, LEGACY_USER_ID);
|
||||
assert.equal(mock.queries[0].sql, 'begin');
|
||||
assert.match(mock.queries[1].sql, /pg_advisory_xact_lock/);
|
||||
assert.deepEqual(mock.queries[1].params, [BOOTSTRAP_LOCK_KEY]);
|
||||
assert.equal(mock.queries.at(-1).sql, 'commit', 'dry-run should retain its transaction lock through normal commit');
|
||||
assert.equal(mock.queries.some(query => query.sql === 'rollback'), false, 'successful dry-run should not break the transaction abstraction');
|
||||
assert.equal(
|
||||
mock.queries.some(query => /^(insert|update|delete)\b/i.test(query.sql)),
|
||||
false,
|
||||
'dry-run must execute no write statement',
|
||||
);
|
||||
assert.match(mock.queries[2].sql, /app\.auth_user_exists/);
|
||||
assert.doesNotMatch(mock.queries[2].sql, /auth\.users/);
|
||||
}
|
||||
|
||||
{
|
||||
const saved = {
|
||||
id: PLATFORM_USER_ID,
|
||||
authUserId: AUTH_USER_ID,
|
||||
username: 'owner.admin',
|
||||
email: 'owner@example.test',
|
||||
phone: '13800001234',
|
||||
};
|
||||
const mock = mockPool([
|
||||
result(),
|
||||
result([{ exists: true }]),
|
||||
result(),
|
||||
result(),
|
||||
result(),
|
||||
result([saved]),
|
||||
result(),
|
||||
]);
|
||||
const applied = await bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool });
|
||||
mock.assertExhausted();
|
||||
assert.equal(applied.action, 'create');
|
||||
assert.equal(applied.auditAction, 'platform.admin.bootstrapped');
|
||||
assert.equal(mock.queries.at(-1).sql, 'commit');
|
||||
|
||||
const userWrite = mock.queries.find(query => query.sql.includes('insert into public.platform_users'));
|
||||
assert.ok(userWrite, 'apply should create the platform user inside the transaction');
|
||||
assert.match(userWrite.sql, /platform_permissions/);
|
||||
assert.match(userWrite.sql, /'\{"\*":true\}'::jsonb/);
|
||||
|
||||
const auditWrite = mock.queries.find(query => query.sql.includes('insert into public.audit_logs'));
|
||||
assert.ok(auditWrite, 'apply should write an audit event in the same transaction');
|
||||
assert.deepEqual(auditWrite.params, [PLATFORM_USER_ID, 'platform.admin.bootstrapped', 'create']);
|
||||
assert.match(auditWrite.sql, /values \( null, null, \$2, 'platform_user', \$1::text/);
|
||||
assert.match(auditWrite.sql, /'invokedBy', 'system_cli'/);
|
||||
const auditPayload = JSON.stringify(auditWrite);
|
||||
assert.equal(auditPayload.includes('owner@example.test'), false, 'audit must not contain email');
|
||||
assert.equal(auditPayload.includes('13800001234'), false, 'audit must not contain phone');
|
||||
assert.equal(auditPayload.includes(AUTH_USER_ID), false, 'audit must not contain the Supabase Auth user ID');
|
||||
|
||||
const output = JSON.stringify(publicResult(applied));
|
||||
assert.equal(output.includes(AUTH_USER_ID), false, 'CLI output must mask Auth user ID');
|
||||
assert.equal(output.includes(PLATFORM_USER_ID), false, 'CLI output must mask platform user ID');
|
||||
assert.equal(output.includes('owner@example.test'), false, 'CLI output must mask email');
|
||||
assert.equal(output.includes('13800001234'), false, 'CLI output must mask phone');
|
||||
assert.equal(output.includes('owner.admin'), false, 'CLI output must mask username');
|
||||
|
||||
const emailUsernameOutput = JSON.stringify(publicResult({ ...applied, username: 'owner@example.test' }));
|
||||
assert.equal(emailUsernameOutput.includes('owner@example.test'), false, 'email-shaped username must be masked');
|
||||
const phoneUsernameOutput = JSON.stringify(publicResult({ ...applied, username: '13800001234' }));
|
||||
assert.equal(phoneUsernameOutput.includes('13800001234'), false, 'phone-shaped username must be masked');
|
||||
}
|
||||
|
||||
{
|
||||
const mock = mockPool([
|
||||
result(),
|
||||
result([{ exists: true }]),
|
||||
result([{ id: PLATFORM_USER_ID }]),
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }),
|
||||
/active, Auth-bound platform admin already exists/,
|
||||
);
|
||||
mock.assertExhausted();
|
||||
assert.equal(mock.queries.at(-1).sql, 'rollback');
|
||||
assert.equal(mock.queries.some(query => query.sql.startsWith('insert into')), false);
|
||||
}
|
||||
|
||||
{
|
||||
const mock = mockPool([
|
||||
result(),
|
||||
result([{ exists: true }]),
|
||||
result(),
|
||||
result(),
|
||||
result([
|
||||
{ id: LEGACY_USER_ID, username: 'legacy.one' },
|
||||
{ id: PLATFORM_USER_ID, username: 'legacy.two' },
|
||||
]),
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }),
|
||||
/Multiple unbound legacy platform admins/,
|
||||
);
|
||||
mock.assertExhausted();
|
||||
assert.equal(mock.queries.at(-1).sql, 'rollback');
|
||||
}
|
||||
|
||||
{
|
||||
const mock = mockPool([
|
||||
result(),
|
||||
result([{ exists: true }]),
|
||||
result(),
|
||||
result(),
|
||||
result([{ id: LEGACY_USER_ID, username: 'legacy.admin', email: null, phone: null, name: 'Legacy Admin' }]),
|
||||
result([{
|
||||
id: LEGACY_USER_ID,
|
||||
authUserId: AUTH_USER_ID,
|
||||
username: 'legacy.admin',
|
||||
email: null,
|
||||
phone: null,
|
||||
}]),
|
||||
result(),
|
||||
]);
|
||||
const applied = await bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool });
|
||||
mock.assertExhausted();
|
||||
assert.equal(applied.action, 'bind_legacy');
|
||||
const userWrite = mock.queries.find(query => query.sql.includes('insert into public.platform_users'));
|
||||
assert.equal(userWrite.params[0], LEGACY_USER_ID, 'unique legacy row should be bound instead of creating a duplicate');
|
||||
const auditWrite = mock.queries.find(query => query.sql.includes('insert into public.audit_logs'));
|
||||
assert.deepEqual(auditWrite.params, [LEGACY_USER_ID, 'platform.admin.bootstrapped', 'bind_legacy']);
|
||||
}
|
||||
|
||||
{
|
||||
const mock = mockPool([
|
||||
result(),
|
||||
result([{ exists: false }]),
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }),
|
||||
/Supabase Auth user not found/,
|
||||
);
|
||||
mock.assertExhausted();
|
||||
assert.equal(mock.queries.at(-1).sql, 'rollback');
|
||||
assert.equal(mock.queries.some(query => /^(insert|update|delete)\b/i.test(query.sql)), false);
|
||||
}
|
||||
|
||||
console.log('[PASS] first platform admin bootstrap safety contract');
|
||||
351
scripts/bootstrap-platform-admin.js
vendored
Normal file
351
scripts/bootstrap-platform-admin.js
vendored
Normal file
@@ -0,0 +1,351 @@
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import pg from 'pg';
|
||||
|
||||
const APPLY_CONFIRMATION = 'BOOTSTRAP_FIRST_PLATFORM_ADMIN';
|
||||
const BOOTSTRAP_LOCK_KEY = 'tiku-saas:first-platform-admin:v1';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function envString(env, key, fallback = '') {
|
||||
return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback;
|
||||
}
|
||||
|
||||
function argumentValue(argv, name) {
|
||||
const directIndex = argv.indexOf(name);
|
||||
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
|
||||
const prefix = `${name}=`;
|
||||
return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim();
|
||||
}
|
||||
|
||||
function normalizeOptionalEmail(value) {
|
||||
if (!value) return null;
|
||||
const email = value.toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
||||
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_EMAIL must be a valid email address');
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
function normalizeOptionalPhone(value) {
|
||||
if (!value) return null;
|
||||
const phone = value.replace(/\s+/g, '');
|
||||
if (!/^\+?[0-9-]{6,32}$/.test(phone)) {
|
||||
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_PHONE must be a valid phone number');
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
function normalizeUsername(value, fallback) {
|
||||
const username = (value || fallback).trim();
|
||||
if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) {
|
||||
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_USERNAME must contain 3-80 safe characters');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function buildConfig(env = process.env, argv = process.argv.slice(2)) {
|
||||
const databaseUrl = envString(env, 'DATABASE_URL');
|
||||
const authUserId = argumentValue(argv, '--auth-user-id') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID');
|
||||
const apply = argv.includes('--apply');
|
||||
const confirmation = argumentValue(argv, '--confirm') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_CONFIRM');
|
||||
const email = normalizeOptionalEmail(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_EMAIL'));
|
||||
const phone = normalizeOptionalPhone(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_PHONE'));
|
||||
const username = normalizeUsername(
|
||||
envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_USERNAME'),
|
||||
`platform_${authUserId.slice(0, 8)}`,
|
||||
);
|
||||
const name = envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_NAME', 'Platform Administrator');
|
||||
|
||||
if (!databaseUrl) throw new Error('Missing required env: DATABASE_URL');
|
||||
if (!UUID_RE.test(authUserId)) {
|
||||
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID or --auth-user-id must be a valid UUID');
|
||||
}
|
||||
if (!name || name.length > 120) throw new Error('BOOTSTRAP_PLATFORM_ADMIN_NAME must contain 1-120 characters');
|
||||
if (apply && confirmation !== APPLY_CONFIRMATION) {
|
||||
throw new Error(`--apply requires --confirm ${APPLY_CONFIRMATION}`);
|
||||
}
|
||||
|
||||
return { databaseUrl, authUserId, apply, confirmation, username, email, phone, name };
|
||||
}
|
||||
|
||||
function maskEmail(value) {
|
||||
if (!value) return null;
|
||||
const [local = '', domain = ''] = String(value).split('@');
|
||||
return `${local.slice(0, 2)}***@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(value) {
|
||||
if (!value) return null;
|
||||
const phone = String(value);
|
||||
return phone.length > 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : '***';
|
||||
}
|
||||
|
||||
function maskUuid(value) {
|
||||
if (!value) return null;
|
||||
const id = String(value);
|
||||
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function maskUsername(value) {
|
||||
if (!value) return null;
|
||||
const username = String(value);
|
||||
if (username.includes('@')) return maskEmail(username);
|
||||
if (/^\+?[0-9-]{6,32}$/.test(username)) return maskPhone(username);
|
||||
if (username.length <= 3) return '***';
|
||||
return `${username.slice(0, 2)}***${username.slice(-1)}`;
|
||||
}
|
||||
|
||||
function publicResult(result) {
|
||||
return {
|
||||
dryRun: result.dryRun,
|
||||
action: result.action,
|
||||
platformUserId: maskUuid(result.platformUserId),
|
||||
authUserId: maskUuid(result.authUserId),
|
||||
username: maskUsername(result.username),
|
||||
email: maskEmail(result.email),
|
||||
phone: maskPhone(result.phone),
|
||||
primaryRole: 'platform_admin',
|
||||
status: 'active',
|
||||
permissions: ['*'],
|
||||
auditAction: result.auditAction || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(error, databaseUrl = '') {
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
if (databaseUrl) message = message.split(databaseUrl).join('[DATABASE_URL_REDACTED]');
|
||||
return message
|
||||
.replace(/postgres(?:ql)?:\/\/[^\s'"<>]+/gi, '[DATABASE_URL_REDACTED]')
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@');
|
||||
}
|
||||
|
||||
async function withTransaction(client, callback) {
|
||||
await client.query('begin');
|
||||
try {
|
||||
await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [BOOTSTRAP_LOCK_KEY]);
|
||||
const result = await callback();
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapPlatformAdmin(inputConfig, options = {}) {
|
||||
const config = inputConfig?.databaseUrl
|
||||
? inputConfig
|
||||
: buildConfig(options.env || process.env, options.argv || process.argv.slice(2));
|
||||
if (config.apply && config.confirmation !== APPLY_CONFIRMATION) {
|
||||
throw new Error(`Apply requires confirmation ${APPLY_CONFIRMATION}`);
|
||||
}
|
||||
const pool = options.pool || new pg.Pool({ connectionString: config.databaseUrl, max: 1 });
|
||||
const closePool = !options.pool;
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
return await withTransaction(client, async () => {
|
||||
const authUserResult = await client.query(
|
||||
`
|
||||
select app.auth_user_exists($1::uuid) as exists
|
||||
`,
|
||||
[config.authUserId],
|
||||
);
|
||||
if (!authUserResult.rows[0]?.exists) throw new Error('Supabase Auth user not found');
|
||||
|
||||
const activeBoundResult = await client.query(
|
||||
`
|
||||
select id
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
and auth_user_id is not null
|
||||
order by created_at asc
|
||||
limit 2
|
||||
for update
|
||||
`,
|
||||
);
|
||||
if (activeBoundResult.rowCount > 0) {
|
||||
throw new Error('An active, Auth-bound platform admin already exists; bootstrap is permanently refused');
|
||||
}
|
||||
|
||||
const boundUserResult = await client.query(
|
||||
`
|
||||
select id, primary_role as "primaryRole"
|
||||
from public.platform_users
|
||||
where auth_user_id = $1::uuid
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[config.authUserId],
|
||||
);
|
||||
if (boundUserResult.rows[0]?.primaryRole !== undefined) {
|
||||
throw new Error('Supabase Auth user is already bound to a platform user');
|
||||
}
|
||||
|
||||
const legacyResult = await client.query(
|
||||
`
|
||||
select id, username, email::text, phone, name
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and auth_user_id is null
|
||||
order by created_at asc
|
||||
limit 2
|
||||
for update
|
||||
`,
|
||||
);
|
||||
if (legacyResult.rowCount > 1) {
|
||||
throw new Error('Multiple unbound legacy platform admins exist; resolve the ambiguity manually');
|
||||
}
|
||||
|
||||
const legacy = legacyResult.rows[0] || null;
|
||||
const action = legacy ? 'bind_legacy' : 'create';
|
||||
const platformUserId = legacy?.id || null;
|
||||
const resolvedEmail = legacy?.email || config.email || null;
|
||||
const resolvedPhone = legacy?.phone || config.phone || null;
|
||||
const resolvedUsername = legacy?.username || config.username;
|
||||
const resolvedName = legacy?.name || config.name;
|
||||
|
||||
if (!config.apply) {
|
||||
return {
|
||||
dryRun: true,
|
||||
action,
|
||||
platformUserId,
|
||||
authUserId: config.authUserId,
|
||||
username: resolvedUsername,
|
||||
email: resolvedEmail,
|
||||
phone: resolvedPhone,
|
||||
auditAction: null,
|
||||
};
|
||||
}
|
||||
|
||||
const savedResult = await client.query(
|
||||
`
|
||||
insert into public.platform_users (
|
||||
id, auth_user_id, username, email, phone, name,
|
||||
primary_role, status, platform_permissions, raw_profile
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, pg_catalog.gen_random_uuid()), $2::uuid, $3, $4::extensions.citext, $5, $6,
|
||||
'platform_admin', 'active', '{"*":true}'::jsonb,
|
||||
jsonb_build_object('source', 'server-bootstrap', 'bootstrapVersion', 1)
|
||||
)
|
||||
on conflict (id)
|
||||
do update set auth_user_id = excluded.auth_user_id,
|
||||
username = coalesce(public.platform_users.username, excluded.username),
|
||||
email = coalesce(public.platform_users.email, excluded.email),
|
||||
phone = coalesce(public.platform_users.phone, excluded.phone),
|
||||
name = coalesce(public.platform_users.name, excluded.name),
|
||||
primary_role = 'platform_admin',
|
||||
status = 'active',
|
||||
platform_permissions = '{"*":true}'::jsonb,
|
||||
raw_profile = jsonb_strip_nulls(public.platform_users.raw_profile || excluded.raw_profile),
|
||||
updated_at = now()
|
||||
returning id, auth_user_id as "authUserId", username, email::text, phone
|
||||
`,
|
||||
[platformUserId, config.authUserId, resolvedUsername, resolvedEmail, resolvedPhone, resolvedName],
|
||||
);
|
||||
const saved = savedResult.rows[0];
|
||||
const auditAction = 'platform.admin.bootstrapped';
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (
|
||||
tenant_id, actor_user_id, action, target_type, target_id, details
|
||||
)
|
||||
values (
|
||||
null, null, $2, 'platform_user', $1::text,
|
||||
jsonb_build_object(
|
||||
'source', 'server-bootstrap',
|
||||
'invokedBy', 'system_cli',
|
||||
'bootstrapVersion', 1,
|
||||
'mode', $3::text,
|
||||
'authUserBound', true,
|
||||
'permissions', jsonb_build_array('*')
|
||||
)
|
||||
)
|
||||
`,
|
||||
[saved.id, auditAction, action],
|
||||
);
|
||||
|
||||
return {
|
||||
dryRun: false,
|
||||
action,
|
||||
platformUserId: saved.id,
|
||||
authUserId: saved.authUserId,
|
||||
username: saved.username,
|
||||
email: saved.email,
|
||||
phone: saved.phone,
|
||||
auditAction,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} finally {
|
||||
if (closePool) await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
return `
|
||||
Bootstrap the first production platform administrator from an existing Supabase Auth user.
|
||||
|
||||
Safety contract:
|
||||
- Runs as a local server CLI and talks directly to DATABASE_URL.
|
||||
- Defaults to dry-run. Writing requires --apply and the exact confirmation phrase.
|
||||
- Refuses once any active, Auth-bound platform admin exists.
|
||||
- May bind exactly one unbound legacy platform_admin row; multiple candidates are refused.
|
||||
- Uses a boolean Auth existence boundary and never reads auth.users profile fields.
|
||||
- Provide optional email/phone explicitly when a new business profile needs them.
|
||||
- Grants {"*":true} and writes a redacted system-CLI audit event in the same locked transaction.
|
||||
|
||||
Usage:
|
||||
DATABASE_URL=<server-database-url> \\
|
||||
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID=<existing-auth.users-id> \\
|
||||
BOOTSTRAP_PLATFORM_ADMIN_USERNAME=<username> \\
|
||||
BOOTSTRAP_PLATFORM_ADMIN_NAME=<display-name> \\
|
||||
npm run bootstrap:platform-admin
|
||||
|
||||
npm run bootstrap:platform-admin -- --apply --confirm ${APPLY_CONFIRMATION}
|
||||
|
||||
Optional identity fields:
|
||||
BOOTSTRAP_PLATFORM_ADMIN_EMAIL
|
||||
BOOTSTRAP_PLATFORM_ADMIN_PHONE
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
console.log(helpText().trim());
|
||||
return;
|
||||
}
|
||||
let config;
|
||||
try {
|
||||
config = buildConfig();
|
||||
const result = await bootstrapPlatformAdmin(config);
|
||||
console.log(JSON.stringify({ ok: true, ...publicResult(result) }, null, 2));
|
||||
if (result.dryRun) {
|
||||
console.error(`Dry-run only. Re-run with --apply --confirm ${APPLY_CONFIRMATION} after reviewing the target.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(sanitizeErrorMessage(error, config?.databaseUrl || envString(process.env, 'DATABASE_URL')));
|
||||
console.error(helpText());
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
|
||||
await main();
|
||||
}
|
||||
|
||||
export {
|
||||
APPLY_CONFIRMATION,
|
||||
BOOTSTRAP_LOCK_KEY,
|
||||
bootstrapPlatformAdmin,
|
||||
buildConfig,
|
||||
helpText,
|
||||
publicResult,
|
||||
sanitizeErrorMessage,
|
||||
};
|
||||
173
scripts/build-weapp-student.js
Normal file
173
scripts/build-weapp-student.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
|
||||
const taroRoot = path.join(repoRoot, 'apps', 'taro');
|
||||
const distProjectConfigPath = path.join(taroRoot, 'dist', 'weapp-student', 'project.config.json');
|
||||
|
||||
const placeholderAppIds = new Set([
|
||||
'wx0000000000000000',
|
||||
'wx0123456789abcdef',
|
||||
'wx1234567890abcdef',
|
||||
'wxabcdef0123456789',
|
||||
]);
|
||||
|
||||
const placeholderTenantCodes = new Set([
|
||||
'changeme',
|
||||
'demo',
|
||||
'example',
|
||||
'placeholder',
|
||||
'production-tenant',
|
||||
'replace-with-tenant-code',
|
||||
'smoke',
|
||||
'tenant-production',
|
||||
'test',
|
||||
]);
|
||||
|
||||
function stringValue(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizedHostname(value) {
|
||||
return stringValue(value).toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function isLoopbackHostname(value) {
|
||||
const hostname = normalizedHostname(value);
|
||||
return hostname === 'localhost'
|
||||
|| hostname.endsWith('.localhost')
|
||||
|| hostname === '::1'
|
||||
|| hostname === '0.0.0.0'
|
||||
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
||||
}
|
||||
|
||||
export function isPlaceholderHostname(value) {
|
||||
const hostname = normalizedHostname(value);
|
||||
return ['example', 'test', 'local'].some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
|
||||
}
|
||||
|
||||
export function validateProductionApiBaseUrl(value) {
|
||||
const apiBaseUrl = stringValue(value);
|
||||
if (!apiBaseUrl) throw new Error('TARO_APP_API_BASE_URL is required for a production WeApp build');
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(apiBaseUrl);
|
||||
} catch {
|
||||
throw new Error('TARO_APP_API_BASE_URL must be an absolute HTTPS URL for a production WeApp build');
|
||||
}
|
||||
if (parsed.protocol !== 'https:' || !parsed.hostname) {
|
||||
throw new Error('TARO_APP_API_BASE_URL must use HTTPS for a production WeApp build');
|
||||
}
|
||||
if (isLoopbackHostname(parsed.hostname)) {
|
||||
throw new Error('TARO_APP_API_BASE_URL must not use localhost or a loopback address for a production WeApp build');
|
||||
}
|
||||
if (isPlaceholderHostname(parsed.hostname)) {
|
||||
throw new Error('TARO_APP_API_BASE_URL must not use a .example, .test, or .local host for a production WeApp build');
|
||||
}
|
||||
return apiBaseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function validateProductionWechatAppId(value) {
|
||||
const appId = stringValue(value);
|
||||
if (!appId) throw new Error('WECHAT_MINIAPP_APP_ID is required for a production WeApp build');
|
||||
if (!/^wx[0-9a-f]{16}$/i.test(appId)) {
|
||||
throw new Error('WECHAT_MINIAPP_APP_ID must be a real 18-character WeChat AppID');
|
||||
}
|
||||
const normalized = appId.toLowerCase();
|
||||
if (placeholderAppIds.has(normalized) || /^wx([0-9a-f])\1{15}$/i.test(normalized)) {
|
||||
throw new Error('WECHAT_MINIAPP_APP_ID must not use a placeholder AppID');
|
||||
}
|
||||
return appId;
|
||||
}
|
||||
|
||||
export function validateTenantCodeFormat(value) {
|
||||
const tenantCode = stringValue(value);
|
||||
if (!tenantCode) throw new Error('TARO_APP_TENANT_CODE is required when TARO_APP_WEAPP_TENANT_MODE=fixed');
|
||||
if (!/^[A-Za-z0-9._-]{2,64}$/.test(tenantCode)) throw new Error('TARO_APP_TENANT_CODE has an invalid format');
|
||||
return tenantCode;
|
||||
}
|
||||
|
||||
export function validateProductionTenantCode(value) {
|
||||
const tenantCode = validateTenantCodeFormat(value);
|
||||
const normalized = tenantCode.toLowerCase();
|
||||
if (placeholderTenantCodes.has(normalized)
|
||||
|| /^(?:tenant[-_])?(?:example|test|demo|smoke|production|placeholder)(?:[-_]tenant)?$/.test(normalized)) {
|
||||
throw new Error('TARO_APP_TENANT_CODE must not use a placeholder tenant code');
|
||||
}
|
||||
return tenantCode;
|
||||
}
|
||||
|
||||
export function resolveWeappTenantMode(env = {}, production = false) {
|
||||
const configuredMode = stringValue(env.TARO_APP_WEAPP_TENANT_MODE).toLowerCase();
|
||||
if (configuredMode && configuredMode !== 'fixed' && configuredMode !== 'launch') {
|
||||
throw new Error('TARO_APP_WEAPP_TENANT_MODE must be fixed or launch');
|
||||
}
|
||||
if (configuredMode) return configuredMode;
|
||||
return production || stringValue(env.TARO_APP_TENANT_CODE) ? 'fixed' : 'launch';
|
||||
}
|
||||
|
||||
export function resolveWeappBuildConfig(env = {}, { production = false } = {}) {
|
||||
const tenantMode = resolveWeappTenantMode(env, production);
|
||||
const configuredTenantCode = stringValue(env.TARO_APP_TENANT_CODE);
|
||||
let tenantCode = '';
|
||||
|
||||
if (tenantMode === 'fixed') {
|
||||
tenantCode = production
|
||||
? validateProductionTenantCode(configuredTenantCode)
|
||||
: validateTenantCodeFormat(configuredTenantCode);
|
||||
} else if (production && configuredTenantCode) {
|
||||
throw new Error('TARO_APP_TENANT_CODE must be empty when TARO_APP_WEAPP_TENANT_MODE=launch');
|
||||
}
|
||||
|
||||
return {
|
||||
production,
|
||||
tenantMode,
|
||||
tenantCode,
|
||||
apiBaseUrl: production
|
||||
? validateProductionApiBaseUrl(env.TARO_APP_API_BASE_URL)
|
||||
: stringValue(env.TARO_APP_API_BASE_URL),
|
||||
appId: production ? validateProductionWechatAppId(env.WECHAT_MINIAPP_APP_ID) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function runWeappBuild({ argv = process.argv.slice(2), env = process.env } = {}) {
|
||||
const production = argv.includes('--production');
|
||||
const buildConfig = resolveWeappBuildConfig(env, { production });
|
||||
const result = spawnSync(process.execPath, [path.join(repoRoot, 'node_modules', '@tarojs', 'cli', 'bin', 'taro'), 'build', '--type', 'weapp'], {
|
||||
cwd: taroRoot,
|
||||
env: {
|
||||
...env,
|
||||
TARO_ENV: 'weapp',
|
||||
TARO_APP_PORTAL: 'student',
|
||||
TARO_APP_RELEASE_MODE: production ? 'production' : 'preview',
|
||||
TARO_APP_API_BASE_URL: buildConfig.apiBaseUrl,
|
||||
TARO_APP_WEAPP_TENANT_MODE: buildConfig.tenantMode,
|
||||
TARO_APP_TENANT_CODE: buildConfig.tenantCode,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) return result.status || 1;
|
||||
|
||||
if (production) {
|
||||
const projectConfig = JSON.parse(fs.readFileSync(distProjectConfigPath, 'utf8'));
|
||||
projectConfig.appid = buildConfig.appId;
|
||||
projectConfig.setting = { ...(projectConfig.setting || {}), urlCheck: true };
|
||||
fs.writeFileSync(distProjectConfigPath, `${JSON.stringify(projectConfig, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
||||
try {
|
||||
process.exitCode = runWeappBuild();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ async function main() {
|
||||
console.error(error.message);
|
||||
console.error(`
|
||||
Required example:
|
||||
DATABASE_URL=postgresql://tiku_app:***@127.0.0.1:54322/postgres
|
||||
DATABASE_URL=postgresql://tiku_api:***@127.0.0.1:54322/postgres
|
||||
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001
|
||||
ALIYUN_ACCESS_KEY_ID=<real-access-key-id>
|
||||
ALIYUN_ACCESS_KEY_SECRET=<real-access-key-secret>
|
||||
|
||||
169
scripts/data-api-security-contract-test.js
Normal file
169
scripts/data-api-security-contract-test.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const migrationPath = path.join(
|
||||
repoRoot,
|
||||
'supabase',
|
||||
'migrations',
|
||||
'202607110001_data_api_acl_rls_hardening.sql',
|
||||
);
|
||||
const authBoundaryMigrationPath = path.join(
|
||||
repoRoot,
|
||||
'supabase',
|
||||
'migrations',
|
||||
'202607120018_auth_user_reference_boundary.sql',
|
||||
);
|
||||
const readinessPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js');
|
||||
const privilegedBootstrapPath = path.join(
|
||||
repoRoot,
|
||||
'scripts',
|
||||
'deploy',
|
||||
'sql',
|
||||
'bootstrap-backend-runtime-roles.sql',
|
||||
);
|
||||
const packagePath = path.join(repoRoot, 'package.json');
|
||||
const taroSourceRoot = path.join(repoRoot, 'apps', 'taro', 'src');
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(filePath) : [filePath];
|
||||
});
|
||||
}
|
||||
|
||||
const migration = read(migrationPath);
|
||||
const authBoundaryMigration = read(authBoundaryMigrationPath);
|
||||
const privilegedBootstrap = read(privilegedBootstrapPath);
|
||||
assert.match(
|
||||
privilegedBootstrap,
|
||||
/revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
|
||||
'the privileged bootstrap must close Supabase base-image extension RPC execution',
|
||||
);
|
||||
assert.match(
|
||||
privilegedBootstrap,
|
||||
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i,
|
||||
'the privileged bootstrap must keep future extension-owner functions closed',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/revoke all privileges on all tables in schema public from public, anon, authenticated/i,
|
||||
'existing public tables must not be exposed to client Data API roles',
|
||||
);
|
||||
assert.match(
|
||||
authBoundaryMigration,
|
||||
/revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
|
||||
'the Auth existence boundary must be denied to every Data API role before the API-only grant',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
authBoundaryMigration,
|
||||
/grant execute on function app\.auth_user_exists\(uuid\) to (?:anon|authenticated|service_role)/i,
|
||||
'the Auth existence boundary must never be exposed through PostgREST roles',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/revoke all privileges on all sequences in schema public from public, anon, authenticated/i,
|
||||
'existing public sequences must not be exposed to client Data API roles',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/revoke all privileges on all functions in schema public from public, anon, authenticated/i,
|
||||
'existing public RPC functions must not be exposed to client Data API roles',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/public functions remain executable by anon\/authenticated; run the privileged backend runtime role bootstrap before migrations/i,
|
||||
'normal migrations must fail closed when Supabase-owned extension functions remain exposed',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/alter default privileges for role %I in schema public revoke all privileges on tables from public, anon, authenticated/i,
|
||||
'future public tables must default to no client Data API grant',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/alter default privileges for role %I in schema public revoke all privileges on sequences from public, anon, authenticated/i,
|
||||
'future public sequences must default to no client Data API grant',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/revoke create on schema public from public, anon, authenticated/i,
|
||||
'client roles must not create objects in the exposed public schema',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated/i,
|
||||
'future public RPC functions must require an explicit execute grant',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/select distinct owner_role\.rolname[\s\S]*pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i,
|
||||
'default privileges must cover every public owner the migration role is authorized to manage',
|
||||
);
|
||||
assert.match(migration, /drop policy if exists platform_admin_platform_users/i);
|
||||
assert.match(
|
||||
migration,
|
||||
/create policy platform_users_self_read[\s\S]*for select[\s\S]*to authenticated[\s\S]*auth_user_id\s*=\s*\(select auth\.uid\(\)\)/i,
|
||||
'platform_users may expose only an explicit self-read policy to authenticated users',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/create policy [^;]+ on public\.platform_users[\s\S]*?for\s+(?:all|insert|update|delete)/i,
|
||||
'platform_users must not have a client write policy',
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/create or replace function app\.is_platform_admin\(\)[\s\S]*security definer[\s\S]*set search_path = ''[\s\S]*from public\.platform_users[\s\S]*status = 'active'/i,
|
||||
'RLS platform authority must come from an active database identity',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/select\s+app\.current_role\(\)\s+in\s*\([^)]*platform_admin/i,
|
||||
'a platform_admin JWT role claim must not be sufficient for RLS authority',
|
||||
);
|
||||
|
||||
const readiness = read(readinessPath);
|
||||
for (const gateId of [
|
||||
'db.data_api.public_table_acl',
|
||||
'db.data_api.public_sequence_acl',
|
||||
'db.data_api.public_function_acl',
|
||||
'db.data_api.public_default_acl',
|
||||
'db.data_api.platform_users_write_policy',
|
||||
'db.rls.platform_admin_authority',
|
||||
]) {
|
||||
assert.ok(readiness.includes(gateId), `production database readiness must include ${gateId}`);
|
||||
}
|
||||
|
||||
const sdkImportViolations = [];
|
||||
const dataApiCallViolations = [];
|
||||
for (const filePath of walk(taroSourceRoot).filter(file => /\.(?:ts|tsx)$/.test(file))) {
|
||||
const source = read(filePath);
|
||||
const relative = path.relative(repoRoot, filePath).replace(/\\/g, '/');
|
||||
if (source.includes('@supabase/supabase-js') && relative !== 'apps/taro/src/services/supabase.ts') {
|
||||
sdkImportViolations.push(relative);
|
||||
}
|
||||
if (!source.includes('ensureSupabaseClient') && !source.includes('getSupabaseClient') && !source.includes('@supabase/supabase-js')) {
|
||||
continue;
|
||||
}
|
||||
for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.(from|rpc)\s*\(/g)) {
|
||||
if (match[1] !== 'Array' && match[1] !== 'Buffer') {
|
||||
dataApiCallViolations.push(`${relative}:${match[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(sdkImportViolations, [], 'Supabase SDK ownership must stay centralized in services/supabase.ts');
|
||||
assert.deepEqual(dataApiCallViolations, [], 'Taro must not access business tables or RPCs through the Data API');
|
||||
|
||||
const rootPackage = JSON.parse(read(packagePath));
|
||||
assert.ok(rootPackage.scripts?.['test:data-api:security'], 'root package must expose the Data API security contract test');
|
||||
assert.ok(
|
||||
rootPackage.scripts?.['test:readiness']?.includes('data-api-security-contract-test.js'),
|
||||
'the production readiness contract suite must run the Data API security test',
|
||||
);
|
||||
|
||||
console.log('[PASS] Supabase Data API deny-by-default security contract');
|
||||
196
scripts/deploy-contract-test.js
Normal file
196
scripts/deploy-contract-test.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function section(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
assert.notEqual(start, -1, `missing section start: ${startMarker}`);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.notEqual(end, -1, `missing section end: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function ordered(source, markers, label) {
|
||||
let cursor = 0;
|
||||
for (const marker of markers) {
|
||||
const index = source.indexOf(marker, cursor);
|
||||
assert.notEqual(index, -1, `${label}: missing or out-of-order marker: ${marker}`);
|
||||
cursor = index + marker.length;
|
||||
}
|
||||
}
|
||||
|
||||
const rootDeploy = read('deploy.sh');
|
||||
const compatDeploy = read('scripts/deploy/bin/deploy.sh');
|
||||
const rootEnv = read('deploy.env.example');
|
||||
const compatEnv = read('scripts/deploy/env/deploy.env.example');
|
||||
const packageJson = read('package.json');
|
||||
const taroPackageJson = read('apps/taro/package.json');
|
||||
const apiService = read('scripts/deploy/systemd/tiku-api.service');
|
||||
const workerService = read('scripts/deploy/systemd/tiku-worker@.service');
|
||||
const workerTarget = read('scripts/deploy/systemd/tiku-workers.target');
|
||||
const apiEnv = read('scripts/deploy/env/api.env.example');
|
||||
const workerEnv = read('scripts/deploy/env/worker.env.example');
|
||||
const runtimeRoleBootstrap = read('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql');
|
||||
const deployReadme = read('scripts/deploy/README.md');
|
||||
|
||||
assert.match(rootDeploy, /: "\$\{WWW_ROOT:=\/srv\/tiku-saas\/www\}"/);
|
||||
assert.match(rootDeploy, /: "\$\{SERVICE_REPO_DIR:=\/opt\/tiku-saas\/repo\}"/);
|
||||
assert.match(rootDeploy, /: "\$\{SERVICE_MODE:=systemd\}"/);
|
||||
assert.match(rootDeploy, /: "\$\{HEALTHCHECK_URL:=http:\/\/127\.0\.0\.1:8787\/health\}"/);
|
||||
assert.match(rootDeploy, /Production deployment requires a service restart strategy/);
|
||||
assert.match(rootDeploy, /Production deployment requires HEALTHCHECK_URL/);
|
||||
assert.match(rootDeploy, /Production systemd deployment requires SYNC_SERVICE_REPO=true/);
|
||||
assert.match(rootDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/);
|
||||
assert.match(compatDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/);
|
||||
assert.match(rootDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/);
|
||||
assert.match(compatDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/);
|
||||
assert.match(rootDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/);
|
||||
assert.match(compatDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/);
|
||||
assert.match(rootDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/);
|
||||
assert.match(compatDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/);
|
||||
assert.match(compatDeploy, /source "\$API_ENV_FILE"/);
|
||||
assert.ok(rootDeploy.includes('LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"'));
|
||||
assert.ok(compatDeploy.includes('LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"'));
|
||||
assert.match(rootDeploy, /LOCK_ACQUIRED=false/);
|
||||
assert.match(compatDeploy, /LOCK_ACQUIRED=false/);
|
||||
assert.match(compatDeploy, /export GIT_TERMINAL_PROMPT=0/);
|
||||
assert.match(rootDeploy, /NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev/);
|
||||
assert.match(rootDeploy, /NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches/);
|
||||
assert.doesNotMatch(rootEnv, /--ignore-scripts/);
|
||||
assert.match(rootEnv, /^NPM_INSTALL_COMMAND="npm ci --workspaces --include-workspace-root --include=dev"$/m);
|
||||
assert.match(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--workspaces[\s\S]*--include-workspace-root[\s\S]*--include=dev/);
|
||||
assert.doesNotMatch(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--ignore-scripts/);
|
||||
assert.match(taroPackageJson, /"postinstall": "node \.\.\/\.\.\/scripts\/taro-components-h5-runtime-patch\.js --apply"/);
|
||||
assert.match(apiService, /WorkingDirectory=\/opt\/tiku-saas\/repo/);
|
||||
assert.match(workerService, /WorkingDirectory=\/opt\/tiku-saas\/repo/);
|
||||
assert.match(workerService, /ExecStart=.*--loop --job %i/);
|
||||
assert.match(workerTarget, /Requires=tiku-worker@crm\.service/);
|
||||
assert.match(workerTarget, /Wants=tiku-worker-monthly-usage\.timer/);
|
||||
|
||||
for (const [dist, target] of [
|
||||
['h5-student', 'student'],
|
||||
['h5-tenant-admin', 'tenant-admin'],
|
||||
['h5-platform-admin', 'platform-admin'],
|
||||
]) {
|
||||
assert.ok(
|
||||
rootDeploy.includes(`$release/apps/taro/dist/${dist}/\" \"$staging/${target}/`),
|
||||
`root deploy must stage ${dist} as ${target}`,
|
||||
);
|
||||
assert.ok(
|
||||
compatDeploy.includes(`$CANDIDATE_RELEASE/apps/taro/dist/${dist}/\" \"$staging/${target}/`),
|
||||
`compat deploy must stage ${dist} as ${target}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rootMain = section(rootDeploy, 'main() {', '\n}\n\nmain "$@"');
|
||||
ordered(rootMain, [
|
||||
'run_build_and_checks "$NEW_RELEASE"',
|
||||
'stage_h5_release "$NEW_RELEASE"',
|
||||
'ROLLBACK_ARMED=true',
|
||||
'switch_current "$NEW_RELEASE"',
|
||||
'sync_service_repo "$NEW_RELEASE"',
|
||||
'restart_services',
|
||||
'healthcheck',
|
||||
'switch_www_release',
|
||||
'verify_live_h5_release "$NEW_RELEASE"',
|
||||
], 'root activation order');
|
||||
assert.ok(rootDeploy.includes('DEPLOY_RELEASE_ROOT="$release"'), 'root launch gate must bind the candidate release root');
|
||||
assert.ok(rootDeploy.includes('--verify-live-h5'), 'root deploy must verify the activated production H5 release');
|
||||
|
||||
const candidateChecks = section(compatDeploy, 'build_and_validate_candidate() {', '\n}\n\nstage_www_candidate() {');
|
||||
ordered(candidateChecks, [
|
||||
'npm run audit:taro:supply-chain',
|
||||
'npm run build:taro:h5:student',
|
||||
'npm run build:taro:h5:platform',
|
||||
'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json"',
|
||||
'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json"',
|
||||
'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json"',
|
||||
'node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config',
|
||||
'npm run manifest:taro:h5 -- --require-dist --require-runtime-config',
|
||||
'npm run smoke:taro:h5',
|
||||
'npm run audit:runtime',
|
||||
'load_runtime_env',
|
||||
'npm run readiness:production',
|
||||
'run_shell "$DB_MIGRATION_COMMAND"',
|
||||
'npm run readiness:production:db',
|
||||
'npm run launch:gate',
|
||||
], 'compat candidate validation order');
|
||||
assert.ok(compatDeploy.includes('DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE"'), 'compat launch gate must bind the candidate release root');
|
||||
|
||||
const rootChecks = section(rootDeploy, 'run_build_and_checks() {', '\n}\n\nverify_live_h5_release() {');
|
||||
ordered(rootChecks, [
|
||||
'npm run audit:taro:supply-chain',
|
||||
'npm run build:taro:h5:student',
|
||||
'npm run audit:runtime',
|
||||
'link_shared_env "$release"',
|
||||
'load_runtime_env',
|
||||
'npm run readiness:production',
|
||||
'run_shell "$DB_MIGRATION_COMMAND"',
|
||||
'npm run readiness:production:db',
|
||||
'npm run launch:gate',
|
||||
], 'root database gate order');
|
||||
|
||||
const compatMain = section(compatDeploy, 'main() {', '\n}\n\nmain "$@"');
|
||||
assert.doesNotMatch(
|
||||
compatMain,
|
||||
/load_runtime_env/,
|
||||
'production secrets must not be loaded before dependency installation and candidate builds',
|
||||
);
|
||||
ordered(compatMain, [
|
||||
'stage_candidate "$release_name"',
|
||||
'build_and_validate_candidate',
|
||||
'stage_www_candidate "$release_name"',
|
||||
'ROLLBACK_ARMED=true',
|
||||
'atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK"',
|
||||
'sync_service_repo "$CANDIDATE_RELEASE"',
|
||||
'restart_services',
|
||||
'healthcheck',
|
||||
'switch_www_release "$candidate_www"',
|
||||
'verify_live_h5_release',
|
||||
], 'compat activation order');
|
||||
assert.ok(compatDeploy.includes('--verify-live-h5'), 'compat deploy must verify the activated production H5 release');
|
||||
assert.ok(!compatDeploy.includes('"$WWW_ROOT/student/"'), 'compat deploy must not overwrite the live student directory');
|
||||
assert.match(compatDeploy, /WWW_RELEASES_DIR:\=\$\{WWW_ROOT%\/\}-releases/);
|
||||
assert.match(compatDeploy, /\.tmp-\$release_name/);
|
||||
|
||||
const compatRollback = section(compatDeploy, 'rollback() {', '\n}\n\ncleanup() {');
|
||||
assert.match(compatRollback, /atomic_symlink "\$PREVIOUS_WWW_RELEASE" "\$WWW_ROOT"/);
|
||||
assert.match(compatRollback, /sync_service_repo "\$PREVIOUS_APP_RELEASE"/);
|
||||
assert.match(compatRollback, /restart_services/);
|
||||
|
||||
assert.match(rootEnv, /^WWW_ROOT=\/srv\/tiku-saas\/www$/m);
|
||||
assert.match(rootEnv, /^SERVICE_REPO_DIR=\/opt\/tiku-saas\/repo$/m);
|
||||
assert.match(rootEnv, /^SERVICE_MODE=systemd$/m);
|
||||
assert.match(rootEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m);
|
||||
assert.match(rootEnv, /^HEALTHCHECK_URL=http:\/\/127\.0\.0\.1:8787\/health$/m);
|
||||
assert.match(compatEnv, /^SOURCE_REPO_DIR=\/opt\/tiku-saas\/source$/m);
|
||||
assert.match(compatEnv, /^REPO_DIR=\/opt\/tiku-saas\/repo$/m);
|
||||
assert.match(rootEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m);
|
||||
assert.match(compatEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m);
|
||||
assert.match(rootEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m);
|
||||
assert.match(compatEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m);
|
||||
assert.match(compatEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m);
|
||||
assert.match(workerEnv, /^STORAGE_DEFAULT_BUCKET=/m);
|
||||
assert.match(workerEnv, /^WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=/m);
|
||||
assert.match(workerEnv, /^WORKER_CRM_POLL_INTERVAL_MS=/m);
|
||||
assert.match(apiEnv, /chown root:deploy, and chmod 640/);
|
||||
assert.doesNotMatch(workerEnv, /^ALIYUN_OSS_BUCKET=/m);
|
||||
assert.doesNotMatch(workerEnv, /^ASSET_SECURITY_SCAN_ENDPOINT=/m);
|
||||
assert.doesNotMatch(workerEnv, /^WORKER_POLL_INTERVAL_MS=/m);
|
||||
assert.match(runtimeRoleBootstrap, /alter role tiku_api[\s\S]*bypassrls/i);
|
||||
assert.match(runtimeRoleBootstrap, /alter role tiku_worker[\s\S]*bypassrls/i);
|
||||
assert.doesNotMatch(runtimeRoleBootstrap, /password\s+['"]/i);
|
||||
assert.match(deployReadme, /bootstrap:db-runtime-roles/);
|
||||
assert.match(deployReadme, /BOOTSTRAP_BACKEND_RUNTIME_ROLES/);
|
||||
assert.ok(
|
||||
packageJson.includes('npm audit --registry=${NPM_AUDIT_REGISTRY:-https://registry.npmjs.org/}'),
|
||||
'npm audit scripts must default to the official audit registry while remaining configurable',
|
||||
);
|
||||
|
||||
console.log('deploy contract: ok');
|
||||
@@ -1,385 +1,470 @@
|
||||
# tiku-supabase 云服务器部署说明
|
||||
# SaaS 题库服务器部署手册
|
||||
|
||||
本文档用于把当前仓库部署到云服务器,并和已经解析好的域名打通。仓库内只保存安全模板,真实密钥、数据库密码、支付密钥、短信密钥、对象存储密钥和 Gitea 部署凭证必须放在服务器 `/etc/tiku-saas/` 下,不能提交到 Git。
|
||||
本手册覆盖两类场景:
|
||||
|
||||
## 域名规划
|
||||
1. 全新测试/预生产服务器的隔离 bootstrap。
|
||||
2. 现有云服务器从旧原地覆盖脚本升级到版本化原子发布。
|
||||
|
||||
建议先按下面 6 个域名落地:
|
||||
生产发布器默认 fail closed。缺少真实数据库 readiness、三端 runtime config、生产 evidence、浏览器烟测环境或服务重启权限时,发布必须失败,不能通过关闭门禁或伪造 evidence 绕过。
|
||||
|
||||
| 域名 | 用途 | 服务器转发 |
|
||||
| --- | --- | --- |
|
||||
| `api.tjszsb.com` | 自研业务 API,Taro/H5/小程序统一调用 | `127.0.0.1:8787` |
|
||||
| `app.tjszsb.com` | 学生 H5 题库端 | `/srv/tiku-saas/www/student` |
|
||||
| `admin.tjszsb.com` | 租户后台 H5 | `/srv/tiku-saas/www/tenant-admin` |
|
||||
| `console.tjszsb.com` | SaaS 平台后台 H5 | `/srv/tiku-saas/www/platform-admin` |
|
||||
| `supabase.tjszsb.com` | Supabase API gateway/Auth/Storage/PostgREST | Supabase gateway,通常是 `127.0.0.1:8000` |
|
||||
| `studio.tjszsb.com` | Supabase Studio 运维后台 | 仅允许固定 IP/VPN/内网访问 |
|
||||
## 先选择入口
|
||||
|
||||
`studio.tjszsb.com` 不建议裸露给公网。若必须临时开放,至少要加 Nginx IP 白名单、强密码、服务器防火墙和访问日志审计。
|
||||
| 场景 | 入口 | 配置模型 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 全新生产服务器 | 仓库根目录 `deploy.sh` | `/opt/tiku-saas/shared/` | 推荐 |
|
||||
| 现有云服务器 | `scripts/deploy/bin/deploy.sh` 安装到 `/opt/tiku-saas/bin/deploy.sh` | `/etc/tiku-saas/` | 兼容升级入口 |
|
||||
| 全新测试/预生产服务器 | 本手册的 staging bootstrap | 完全独立目录、数据库、域名和凭据 | 当前无一键 staging profile |
|
||||
|
||||
## 服务器目录
|
||||
两套生产脚本的变量名不同,不能混用 env 模板:
|
||||
|
||||
推荐使用固定目录,方便后续脚本和 AI 协作不漂移:
|
||||
- 根脚本:`REPO_URL`、`BRANCH`、`DEPLOY_ROOT`,模板为根目录 `deploy.env.example`。
|
||||
- 兼容脚本:`GIT_REPO`、`GIT_BRANCH`、`APP_ROOT`,模板为 `scripts/deploy/env/deploy.env.example`。
|
||||
|
||||
```text
|
||||
/opt/tiku-saas/repo Git 工作副本
|
||||
/opt/tiku-saas/bin 服务器本地执行脚本
|
||||
/srv/tiku-saas/www/student 学生端 H5 静态文件
|
||||
/srv/tiku-saas/www/tenant-admin 租户后台 H5 静态文件
|
||||
/srv/tiku-saas/www/platform-admin 平台后台 H5 静态文件
|
||||
/srv/tiku-saas/data 运行期数据
|
||||
/srv/tiku-saas/backups 数据库和对象存储备份
|
||||
/etc/tiku-saas/deploy.env 部署脚本配置,含 Gitea 只读部署凭证
|
||||
/etc/tiku-saas/api.env API 生产环境变量
|
||||
/etc/tiku-saas/worker.env worker 生产环境变量
|
||||
/etc/tiku-saas/runtime-config/ 三套 H5 公开运行时配置
|
||||
```
|
||||
|
||||
建议创建独立低权限用户:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --create-home --shell /bin/bash deploy
|
||||
sudo mkdir -p /opt/tiku-saas/bin /srv/tiku-saas/www/student /srv/tiku-saas/www/tenant-admin /srv/tiku-saas/www/platform-admin /srv/tiku-saas/data /srv/tiku-saas/backups /etc/tiku-saas/runtime-config
|
||||
sudo chown -R deploy:deploy /opt/tiku-saas /srv/tiku-saas
|
||||
sudo chmod 750 /etc/tiku-saas
|
||||
```
|
||||
|
||||
## 宝塔服务器实际落地记录
|
||||
|
||||
2026-07-01 首次上云使用的是 Alibaba Cloud Linux 3 + 宝塔面板环境。该服务器的 80/443 已由宝塔 Nginx 接管,主配置不在 `/etc/nginx`,而在:
|
||||
|
||||
```text
|
||||
/www/server/nginx/conf/nginx.conf
|
||||
/www/server/panel/vhost/nginx/*.conf
|
||||
```
|
||||
|
||||
因此在这类服务器上不要执行 `systemctl start nginx`、不要写 `/etc/nginx/sites-available`,也不要覆盖宝塔生成的站点配置。宝塔 Nginx 的测试和重载命令是:
|
||||
|
||||
```bash
|
||||
/www/server/nginx/sbin/nginx -t -c /www/server/nginx/conf/nginx.conf
|
||||
/www/server/nginx/sbin/nginx -s reload
|
||||
```
|
||||
|
||||
本次保留企业目录隔离方案:
|
||||
|
||||
```text
|
||||
/opt/tiku-saas/repo Gitea 工作副本
|
||||
/opt/tiku-saas/bin 服务器部署脚本
|
||||
/srv/tiku-saas/www H5 发布产物
|
||||
/srv/tiku-saas/data 运行数据
|
||||
/srv/tiku-saas/backups 备份
|
||||
/etc/tiku-saas 真实 env、Gitea token、运行时配置
|
||||
```
|
||||
|
||||
宝塔新增站点时会拦截 `/srv` 作为网站根目录。不要因此把密钥、仓库或运行数据搬进 `/www`。只为 H5 静态站点创建 `/www/wwwroot` 下的软链接:
|
||||
|
||||
```bash
|
||||
mkdir -p /www/wwwroot/tiku-saas
|
||||
ln -sfn /srv/tiku-saas/www/student /www/wwwroot/tiku-saas/student
|
||||
ln -sfn /srv/tiku-saas/www/tenant-admin /www/wwwroot/tiku-saas/tenant-admin
|
||||
ln -sfn /srv/tiku-saas/www/platform-admin /www/wwwroot/tiku-saas/platform-admin
|
||||
chown -h deploy:deploy /www/wwwroot/tiku-saas/student
|
||||
chown -h deploy:deploy /www/wwwroot/tiku-saas/tenant-admin
|
||||
chown -h deploy:deploy /www/wwwroot/tiku-saas/platform-admin
|
||||
```
|
||||
|
||||
宝塔面板中新增三个纯静态站点:
|
||||
|
||||
| 域名 | 宝塔根目录 |
|
||||
| --- | --- |
|
||||
| `app.tjszsb.com` | `/www/wwwroot/tiku-saas/student` |
|
||||
| `admin.tjszsb.com` | `/www/wwwroot/tiku-saas/tenant-admin` |
|
||||
| `console.tjszsb.com` | `/www/wwwroot/tiku-saas/platform-admin` |
|
||||
|
||||
每个站点需要保留 H5 history fallback,并禁止缓存公开运行时配置:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /runtime-config.json {
|
||||
add_header Cache-Control "no-store" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
```
|
||||
|
||||
当前服务器已经验证过的基础环境:
|
||||
|
||||
```text
|
||||
Node.js: v20.20.2,系统级安装在 /usr/bin/node,deploy 用户可用
|
||||
npm: 10.8.2,deploy 用户可用
|
||||
Docker: 26.1.3
|
||||
Docker Compose: v2.27.0
|
||||
Nginx: 宝塔 /www/server/nginx/sbin/nginx,1.30.1
|
||||
```
|
||||
|
||||
不要使用 root 的 nvm Node 路径作为生产运行时。若 `deploy` 用户看不到 Node/NPM,应安装系统级 NodeSource Node.js 20:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -
|
||||
dnf install -y nodejs
|
||||
sudo -u deploy bash -lc 'command -v node; command -v npm; node -v; npm -v'
|
||||
```
|
||||
|
||||
大陆服务器 `npm ci` 可能访问 npm 官方源超时。本次部署在 `/etc/tiku-saas/deploy.env` 中使用可配置 npm registry 和重试参数:
|
||||
|
||||
```bash
|
||||
NPM_REGISTRY=https://registry.npmmirror.com
|
||||
NPM_FETCH_RETRIES=5
|
||||
NPM_FETCH_RETRY_MINTIMEOUT=20000
|
||||
NPM_FETCH_RETRY_MAXTIMEOUT=120000
|
||||
NPM_FETCH_TIMEOUT=300000
|
||||
```
|
||||
|
||||
截至 2026-07-01 21:39,`sudo -u deploy /opt/tiku-saas/bin/deploy.sh` 已完成:
|
||||
|
||||
- Gitea `main` 拉取到 `/opt/tiku-saas/repo`。
|
||||
- `npm ci` 安装依赖。
|
||||
- `npm run security:repo`,结果 0 finding。
|
||||
- `node scripts/production-launch-gate-test.js`,通过。
|
||||
- API 和 worker 构建通过。
|
||||
- 学生端、租户后台、平台后台三套 Taro H5 构建通过。
|
||||
- H5 发布到 `/srv/tiku-saas/www/student`、`/srv/tiku-saas/www/tenant-admin`、`/srv/tiku-saas/www/platform-admin`。
|
||||
- 三个 `runtime-config.json` 已安装到各自 H5 根目录。
|
||||
|
||||
Taro H5 构建存在 webpack asset size warning,这是前端包体优化事项,不影响当前部署继续进行。后续可做拆包、按需加载和 KaTeX 字体裁剪。
|
||||
|
||||
## 服务器接管和故障恢复
|
||||
|
||||
2026-07-03 最新接管状态:
|
||||
|
||||
- Gitea `main` 已包含 PNVS 短信认证、后台登录修复、PNVS provider 配置/诊断脚本和旧题库视觉对齐版本,最新提交应至少是 `4fb4125`。
|
||||
- 服务器仓库仍在 `/opt/tiku-saas/repo`,归属用户应为 `deploy:deploy`。
|
||||
- 生产 API 已能启动,`https://api.tjszsb.com/api/tenant/resolve?host=app.tjszsb.com` 已返回 `master` 租户。
|
||||
- Supabase self-hosted 运行在 `/opt/tiku-saas/supabase-project`,Kong 通过 Nginx 暴露到 `https://supabase.tjszsb.com`。
|
||||
- 线上 H5 公开配置文件在 `/srv/tiku-saas/www/*/runtime-config.json`,密钥只允许放 `supabasePublishableKey` 这类公开 key。
|
||||
- 短信验证码登录生产必须使用 `AUTH_SMS_PROVIDER=aliyun-pnvs`。阿里云 AccessKey/Secret 只写入 `app_private.tenant_secrets(secret_scope='sms', secret_key='aliyun-pnvs')`,不要写进 `/etc/tiku-saas/api.env` 或 H5 `runtime-config.json`。
|
||||
|
||||
配置 PNVS provider 推荐用仓库脚本写入数据库,避免手写 SQL 时把密钥打进命令历史。生产环境建议临时关闭 shell history,再用 `read -s` 输入 AccessKeySecret:
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas/repo
|
||||
set -a
|
||||
source /etc/tiku-saas/api.env
|
||||
set +a
|
||||
set +o history
|
||||
read -r -p 'Aliyun AccessKeyId: ' ALIYUN_ACCESS_KEY_ID
|
||||
read -r -s -p 'Aliyun AccessKeySecret: ' ALIYUN_ACCESS_KEY_SECRET; echo
|
||||
read -r -p 'PNVS SignName: ' ALIYUN_PNVS_SIGN_NAME
|
||||
read -r -p 'PNVS TemplateCode: ' ALIYUN_PNVS_TEMPLATE_CODE
|
||||
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 \
|
||||
ALIYUN_ACCESS_KEY_ID="$ALIYUN_ACCESS_KEY_ID" \
|
||||
ALIYUN_ACCESS_KEY_SECRET="$ALIYUN_ACCESS_KEY_SECRET" \
|
||||
ALIYUN_PNVS_SIGN_NAME="$ALIYUN_PNVS_SIGN_NAME" \
|
||||
ALIYUN_PNVS_TEMPLATE_CODE="$ALIYUN_PNVS_TEMPLATE_CODE" \
|
||||
npm run configure:aliyun-pnvs
|
||||
unset ALIYUN_ACCESS_KEY_ID ALIYUN_ACCESS_KEY_SECRET ALIYUN_PNVS_SIGN_NAME ALIYUN_PNVS_TEMPLATE_CODE
|
||||
set -o history
|
||||
```
|
||||
|
||||
配置后先跑只读诊断,确认 env、`tenant_auth_providers` 和 `tenant_secrets` 对齐;输出只包含 AccessKey 长度和脱敏前后缀,不会打印密钥明文:
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas/repo
|
||||
set -a
|
||||
source /etc/tiku-saas/api.env
|
||||
set +a
|
||||
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs
|
||||
```
|
||||
|
||||
如果 `readiness:production:db` 报 `legacy_sms_provider`,先 dry-run 查看仍处于 `active/testing` 的传统短信 provider,再确认停用。这个脚本只会处理 `aliyun`、`aliyun-sms`、`tencent`、`tencent-sms` 等旧短信 auth provider,不会改 PNVS 行:
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas/repo
|
||||
set -a
|
||||
source /etc/tiku-saas/api.env
|
||||
set +a
|
||||
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run disable:legacy-sms-providers
|
||||
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run disable:legacy-sms-providers -- --apply
|
||||
```
|
||||
|
||||
接管服务器时先做只读检查:
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas/repo
|
||||
sudo -u deploy git status --short
|
||||
sudo -u deploy git log -3 --oneline
|
||||
sudo -u deploy git remote -v
|
||||
systemctl status tiku-api --no-pager -l
|
||||
systemctl status tiku-worker --no-pager -l
|
||||
docker compose -f /opt/tiku-saas/supabase-project/docker-compose.yml ps
|
||||
```
|
||||
|
||||
Gitea SSH 使用 `2222` 端口,推荐服务器 `deploy` 用户的 remote 使用完整 SSH URL:
|
||||
|
||||
```bash
|
||||
sudo -u deploy git -C /opt/tiku-saas/repo remote set-url origin ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git
|
||||
sudo -u deploy ssh -o BatchMode=yes -T -p 2222 git@git.gongxue100.com
|
||||
sudo -u deploy git -C /opt/tiku-saas/repo pull origin main
|
||||
```
|
||||
|
||||
也可以写 `/home/deploy/.ssh/config`,但必须包含 `Port 2222`:
|
||||
|
||||
```sshconfig
|
||||
Host git.gongxue100.com
|
||||
HostName git.gongxue100.com
|
||||
User git
|
||||
Port 2222
|
||||
IdentityFile /home/deploy/.ssh/tiku_saas_deploy
|
||||
IdentitiesOnly yes
|
||||
```
|
||||
|
||||
如果 Taro 构建长时间没有输出,先判断它是真在编译还是已经卡死。构建中的 Taro/webpack 可能会有一段时间安静,但如果 `dist` 目录大小和最近修改时间 30 秒以上都不变,就按卡住处理:
|
||||
|
||||
```bash
|
||||
ps -eo pid,ppid,user,stat,etime,%cpu,%mem,cmd | grep -E 'npm|node|taro|webpack' | grep -v grep
|
||||
du -sh /opt/tiku-saas/repo/apps/taro/dist/h5-student
|
||||
sleep 30
|
||||
du -sh /opt/tiku-saas/repo/apps/taro/dist/h5-student
|
||||
find /opt/tiku-saas/repo/apps/taro/dist/h5-student -type f -mmin -5 | head -20
|
||||
```
|
||||
|
||||
确认卡住后,先在原终端 `Ctrl+C`。如果仍有残留 Taro 构建进程,再只结束这条构建链路,不要杀生产 API、Supabase 或其它 Node 服务:
|
||||
|
||||
```bash
|
||||
ps -eo pid,ppid,user,stat,etime,%cpu,%mem,cmd | grep -E 'npm run build:taro|taro build --type h5|webpack' | grep -v grep
|
||||
kill <pid>
|
||||
sleep 3
|
||||
kill -9 <pid>
|
||||
```
|
||||
|
||||
然后清理单端产物并带 CI/内存参数重跑。先单独跑学生端,成功后再跑另外两端:
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas/repo
|
||||
rm -rf apps/taro/dist/h5-student
|
||||
sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:student
|
||||
|
||||
rm -rf apps/taro/dist/h5-tenant-admin apps/taro/dist/h5-platform-admin
|
||||
sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:tenant
|
||||
sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:platform
|
||||
```
|
||||
|
||||
三端构建成功后再发布:
|
||||
服务器长期更新命令可以继续保持:
|
||||
|
||||
```bash
|
||||
sudo -u deploy /opt/tiku-saas/bin/deploy.sh
|
||||
```
|
||||
|
||||
如果只是想接管代码开发,不要从服务器 `apps/taro/dist` 或 `/srv/tiku-saas/www` 拷贝产物;它们只是发布结果,源码以 Gitea `main` 为准。
|
||||
但首次发布当前基线前,必须先升级 `/opt/tiku-saas/bin/deploy.sh`、部署配置、API/Worker env 和 systemd units。发布脚本不会自动更新自己,也不会自动安装 Nginx 或 systemd 模板。
|
||||
|
||||
## 首次安装
|
||||
## 发布能力与边界
|
||||
|
||||
1. 安装基础组件:Docker、Docker Compose、Node.js 20+、Nginx、Certbot、Git、rsync、flock。
|
||||
2. 按 Supabase 官方 self-hosting Docker 文档部署 Supabase。生产必须启用 HTTPS 反向代理,Supabase 官方也要求生产自托管部署使用 HTTPS。
|
||||
3. 把本目录模板复制到服务器:
|
||||
新版生产部署器会:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/tiku-saas/bin /etc/tiku-saas/runtime-config
|
||||
sudo cp scripts/deploy/bin/deploy.sh /opt/tiku-saas/bin/deploy.sh
|
||||
sudo cp scripts/deploy/env/deploy.env.example /etc/tiku-saas/deploy.env
|
||||
sudo cp scripts/deploy/env/api.env.example /etc/tiku-saas/api.env
|
||||
sudo cp scripts/deploy/env/worker.env.example /etc/tiku-saas/worker.env
|
||||
sudo cp scripts/deploy/runtime-config/h5-student.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-student.runtime-config.json
|
||||
sudo cp scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-tenant-admin.runtime-config.json
|
||||
sudo cp scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-platform-admin.runtime-config.json
|
||||
sudo chmod 700 /opt/tiku-saas/bin/deploy.sh
|
||||
sudo chmod 600 /etc/tiku-saas/*.env /etc/tiku-saas/runtime-config/*.json
|
||||
1. 获取固定分支的最新 commit,并在独立候选 release 安装锁定依赖。
|
||||
2. 运行 TypeScript、仓库安全扫描、runtime audit 和 Taro 供应链审计。
|
||||
3. 构建 API、Worker、学生 H5、租户后台 H5、平台后台 H5。
|
||||
4. 注入三端公开 runtime config,运行严格 guard、manifest、25 项静态 smoke 和 33 项真实浏览器交互 smoke。
|
||||
5. 运行生产 env readiness;可选执行 migration;随后运行数据库 readiness。
|
||||
6. 使用与 commit、artifact 和 SHA-256 绑定的真实 production launch evidence。
|
||||
7. 原子切换应用与 Web release,重启服务,验证 API `/health` 和线上 H5 hash。
|
||||
8. 失败时恢复上一份代码和 Web release。
|
||||
|
||||
脚本不会:
|
||||
|
||||
- 创建数据库或对象存储备份。
|
||||
- 回滚已经提交的 migration。
|
||||
- 自动更新 `/etc/systemd/system`、宝塔 Nginx 或 `/etc/tiku-saas/*.env`。
|
||||
- 逐一确认所有 Worker backlog、Provider、外部告警和业务数据正确性。
|
||||
- 自动创建首个平台超管。
|
||||
|
||||
因此 migration 必须先备份并演练向后兼容。代码/Web 回滚不能被当作数据库回滚。
|
||||
|
||||
## 域名与目录
|
||||
|
||||
参考域名:
|
||||
|
||||
```text
|
||||
api.tjszsb.com Node.js API
|
||||
app.tjszsb.com 学生 H5
|
||||
admin.tjszsb.com 租户后台 H5
|
||||
console.tjszsb.com 平台后台 H5
|
||||
supabase.tjszsb.com Supabase Gateway/Auth/Data API
|
||||
studio.tjszsb.com Supabase Studio,必须限制来源
|
||||
```
|
||||
|
||||
4. 编辑 `/etc/tiku-saas/*.env` 和 `/etc/tiku-saas/runtime-config/*.json`,填入真实生产配置。
|
||||
5. 安装 systemd 服务:
|
||||
现有生产兼容布局:
|
||||
|
||||
```bash
|
||||
sudo cp scripts/deploy/systemd/tiku-api.service /etc/systemd/system/tiku-api.service
|
||||
sudo cp scripts/deploy/systemd/tiku-worker.service /etc/systemd/system/tiku-worker.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable tiku-api tiku-worker
|
||||
```text
|
||||
/opt/tiku-saas/source Gitea 源码副本,只用于 fetch/build
|
||||
/opt/tiku-saas/releases 版本化候选应用
|
||||
/opt/tiku-saas/current 当前应用 release 软链接
|
||||
/opt/tiku-saas/repo systemd 当前运行副本
|
||||
/opt/tiku-saas/bin/deploy.sh 服务器外置兼容部署器
|
||||
/srv/tiku-saas/www 当前三端 Web release 软链接
|
||||
/srv/tiku-saas/www-releases 版本化 Web release
|
||||
/etc/tiku-saas/deploy.env 部署配置和只读 Gitea 凭据
|
||||
/etc/tiku-saas/api.env API 生产 env
|
||||
/etc/tiku-saas/worker.env Worker 生产 env
|
||||
/etc/tiku-saas/runtime-config/ 三端公开 runtime config
|
||||
/etc/tiku-saas/production-launch-evidence.json
|
||||
/etc/tiku-saas/launch-artifacts/ 与 evidence 配套的证据文件
|
||||
```
|
||||
|
||||
6. 安装 Nginx 配置:
|
||||
根部署器使用 `/opt/tiku-saas/shared/` 保存 deploy env、readiness env、runtime config 和 evidence。systemd 模板目前仍从 `/etc/tiku-saas/api.env` 与 `/etc/tiku-saas/worker.env` 读取运行配置,因此使用根脚本时也必须维护这两个文件;`shared/.env` 只用于候选 release 的 readiness,关键 API 配置必须与 `/etc/tiku-saas/api.env` 保持一致,避免双配置漂移。现有服务器优先使用兼容入口,减少这项差异。
|
||||
|
||||
```bash
|
||||
sudo cp scripts/deploy/nginx/tjszsb.com.conf.example /etc/nginx/sites-available/tiku-saas.conf
|
||||
sudo ln -s /etc/nginx/sites-available/tiku-saas.conf /etc/nginx/sites-enabled/tiku-saas.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
## 凭据
|
||||
|
||||
- 已经出现在聊天、工单、截图或日志里的 token 必须吊销。
|
||||
- 服务器优先使用只读 SSH deploy key;Gitea SSH 端口为 `2222`。
|
||||
- 使用 HTTPS 时,token 只放权限为 `600/640` 的服务器 env,由临时 `GIT_ASKPASS` 注入。
|
||||
- token 不得写入 Git remote、脚本、README 或命令历史。
|
||||
- `DATABASE_ADMIN_URL`、`DATABASE_MIGRATION_URL` 只从密码管理器临时注入,不长期写入 deploy/API/Worker env。
|
||||
|
||||
SSH remote:
|
||||
|
||||
```text
|
||||
ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git
|
||||
```
|
||||
|
||||
7. 申请 HTTPS 证书:
|
||||
## 新测试服务器
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d api.tjszsb.com -d app.tjszsb.com -d admin.tjszsb.com -d console.tjszsb.com -d supabase.tjszsb.com -d studio.tjszsb.com
|
||||
测试服务器必须与生产完全隔离:
|
||||
|
||||
```text
|
||||
/opt/tiku-saas-staging
|
||||
/srv/tiku-saas-staging
|
||||
/etc/tiku-saas-staging
|
||||
staging-api.example.com
|
||||
staging-app.example.com
|
||||
staging-admin.example.com
|
||||
staging-console.example.com
|
||||
独立 PostgreSQL/Supabase、Auth、bucket 和 Provider 测试账号
|
||||
```
|
||||
|
||||
## Gitea 凭证
|
||||
数据库安全标记必须是:
|
||||
|
||||
优先推荐 SSH deploy key。若暂时使用 Gitea token,必须新建一个只读部署 token,并写入 `/etc/tiku-saas/deploy.env`,不要把 token 写进脚本、Git remote、命令历史或 README。
|
||||
|
||||
已经在聊天、工单、截图里出现过的 token 都应当视为暴露,正式上云前请立即吊销并重新生成。
|
||||
|
||||
`deploy.sh` 会通过临时 `GIT_ASKPASS` 给 `git clone/fetch` 提供账号和 token,避免 token 出现在 `git remote -v` 里。
|
||||
|
||||
## 更新发布
|
||||
|
||||
服务器上执行:
|
||||
|
||||
```bash
|
||||
sudo -u deploy /opt/tiku-saas/bin/deploy.sh
|
||||
```text
|
||||
environment=staging
|
||||
allow_destructive_tests=false
|
||||
```
|
||||
|
||||
脚本会执行:
|
||||
API 与 Worker 应继续使用 `NODE_ENV=production`,这样能验证生产配置 fail-fast。staging 不能使用 production 数据库、bucket、支付/短信密钥或真实用户流量,也不能运行 `supabase db reset`、`db:smoke-seed:test`、`test:api`、`test:rls` 和会写入集成夹具的 Worker 测试。
|
||||
|
||||
1. 获取 `main` 最新代码。
|
||||
2. `npm ci` 安装锁定依赖。
|
||||
3. 运行仓库安全扫描和生产上线门禁测试。
|
||||
4. 构建 API、worker、学生 H5、租户后台 H5、平台后台 H5。
|
||||
5. 用 `rsync --delete` 发布静态产物。
|
||||
6. 复制服务器本地 `runtime-config.json` 到对应 Web 根目录。
|
||||
7. 重启 `tiku-api` 和 `tiku-worker`。
|
||||
8. 输出当前发布的 Git commit。
|
||||
当前仓库没有经过验证的一键 staging profile。两套 deploy 脚本在 production 模式下都会强制真实 launch gate,launch evidence 也不能用本地 mock 伪造。首次测试服务器采用下面的分阶段 bootstrap;在 staging profile 被单独实现和验证前,不要直接套用生产 `/opt/tiku-saas`、`/srv/tiku-saas` 和 `/etc/tiku-saas` 路径。
|
||||
|
||||
## 上线前检查
|
||||
|
||||
每次正式放量前至少执行:
|
||||
### 1. 固定候选 commit
|
||||
|
||||
```bash
|
||||
npm run security:repo
|
||||
node scripts/production-launch-gate-test.js
|
||||
node scripts/launch-persona-smoke-test.js
|
||||
sudo install -d -o deploy -g deploy /opt/tiku-saas-staging/source
|
||||
sudo -u deploy git clone \
|
||||
ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git \
|
||||
/opt/tiku-saas-staging/source
|
||||
sudo -u deploy git -C /opt/tiku-saas-staging/source checkout <reviewed-commit-sha>
|
||||
```
|
||||
|
||||
不要在验证期间继续移动候选分支。
|
||||
|
||||
### 2. 安装与构建预检
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas-staging/source
|
||||
sudo -u deploy npm ci --workspaces --include-workspace-root --include=dev
|
||||
sudo -u deploy npm run check:api
|
||||
sudo -u deploy npm run check:worker
|
||||
sudo -u deploy npm run check:taro
|
||||
sudo -u deploy npm run security:repo
|
||||
sudo -u deploy npm run audit:runtime
|
||||
sudo -u deploy npm run audit:taro:supply-chain
|
||||
sudo -u deploy npm run build:api
|
||||
sudo -u deploy npm run build:worker
|
||||
sudo -u deploy npm run build:taro:h5:student
|
||||
sudo -u deploy npm run build:taro:h5:tenant
|
||||
sudo -u deploy npm run build:taro:h5:platform
|
||||
```
|
||||
|
||||
服务器必须安装 Chrome/Chromium,或设置 `TARO_H5_SMOKE_BROWSER` 指向受支持浏览器。随后运行:
|
||||
|
||||
```bash
|
||||
sudo -u deploy npm run smoke:taro:h5
|
||||
sudo -u deploy env TARO_H5_INTERACTION_OUTPUT_DIR=/tmp/tiku-h5-staging \
|
||||
npm run smoke:taro:h5:interaction
|
||||
```
|
||||
|
||||
这一步只证明候选产物和本地 mock 旅程,不是生产 evidence。
|
||||
|
||||
### 3. 数据库 bootstrap 与 migration
|
||||
|
||||
先创建数据库和对象存储快照,再 dry-run runtime role 计划:
|
||||
|
||||
```bash
|
||||
cd /opt/tiku-saas-staging/source
|
||||
npm run bootstrap:db-runtime-roles
|
||||
```
|
||||
|
||||
由真正 PostgreSQL superuser 应用一次:
|
||||
|
||||
```bash
|
||||
DATABASE_ADMIN_URL='<secret-managed-superuser-url>' \
|
||||
npm run bootstrap:db-runtime-roles -- \
|
||||
--apply --confirm=BOOTSTRAP_BACKEND_RUNTIME_ROLES
|
||||
```
|
||||
|
||||
bootstrap 不设置角色密码。用密码管理器为 `tiku_api`、`tiku_worker` 设置独立强密码,再把相应连接分别写入 staging API/Worker env。
|
||||
|
||||
全新数据库在 migrations 后没有业务租户数据,生产 readiness 会因没有 active tenant 而阻断。使用受控管理流程创建 staging 的 master tenant、active domain、branding/settings 和必要 Provider 配置;不要执行 `supabase/seed.sql`,它是本地开发 seed,会把数据库标为 `local` 并写入 mock 数据。
|
||||
|
||||
由标准 migration role 应用迁移:
|
||||
|
||||
```bash
|
||||
DATABASE_MIGRATION_URL='<secret-managed-migration-url>' \
|
||||
supabase db push --db-url "$DATABASE_MIGRATION_URL"
|
||||
```
|
||||
|
||||
然后使用 API 运行角色验证:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source /etc/tiku-saas-staging/api.env
|
||||
set +a
|
||||
npm run readiness:production
|
||||
```
|
||||
|
||||
接入真实生产配置后,还要在服务器上补跑:
|
||||
|
||||
```bash
|
||||
npm run readiness:production:db
|
||||
npm run smoke:auth:remote
|
||||
SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-real-phone SMS_SMOKE_ORIGIN=https://admin.tjszsb.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json
|
||||
npm run perf:api:local
|
||||
```
|
||||
|
||||
如果要同时验证手机号绑定也走 PNVS provider verification,准备一个未绑定测试手机号后执行:
|
||||
migration 是前向操作。失败时按备份恢复方案处理,不依赖 deploy symlink 回滚。
|
||||
|
||||
### 4. runtime config 与受控激活
|
||||
|
||||
从 `scripts/deploy/runtime-config/*.example.json` 创建 staging 文件。浏览器 H5 使用 Origin 解析租户时,`tenantCode` 保持空字符串;只允许公开 HTTPS URL 和 Supabase publishable key。
|
||||
|
||||
在尚未具备 staging 原子发布 profile 时,先由运维在隔离路径安装 systemd/Nginx,明确每一个 WorkingDirectory、EnvironmentFile、端口、域名和 Web root 都指向 `*-staging`。不得直接复制当前生产 unit 后仍保留 `/opt/tiku-saas/repo` 或 `/etc/tiku-saas`。
|
||||
|
||||
完成真实 staging Auth/CORS/Provider/三类 persona、Worker 和线上 H5 检查后,将报告保存到 staging 自己的 evidence bundle。production launch evidence 仍只能由最终生产候选生成,不能从 staging 复制冒充。
|
||||
|
||||
## 现有服务器一次性升级
|
||||
|
||||
### 1. 冻结与备份
|
||||
|
||||
1. 将完整基线合并到 Gitea,记录待部署 SHA。
|
||||
2. 备份 PostgreSQL、对象存储、`/etc/tiku-saas`、旧部署脚本、旧运行目录和宝塔 Nginx 配置。
|
||||
3. 验证备份可读,并记录数据库恢复与代码/Web 回滚步骤。
|
||||
4. 首次 migration 推荐独立执行“备份 -> migration -> DB readiness -> evidence”,正式应用发布时恢复 `RUN_DB_MIGRATIONS=false`。
|
||||
|
||||
### 2. 准备 source checkout
|
||||
|
||||
从一个不依赖旧部署脚本的临时目录获取新代码:
|
||||
|
||||
```bash
|
||||
SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-login-phone SMS_SMOKE_BIND_PHONE=replace-with-bind-phone SMS_SMOKE_ORIGIN=https://admin.tjszsb.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json
|
||||
sudo install -d -o deploy -g deploy /opt/tiku-saas/source
|
||||
sudo -u deploy git clone \
|
||||
ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git \
|
||||
/opt/tiku-saas/source
|
||||
sudo -u deploy git -C /opt/tiku-saas/source checkout <reviewed-commit-sha>
|
||||
```
|
||||
|
||||
压测必须在目标云服务器、目标数据库参数、目标对象存储和目标 Nginx 配置下重新计算,本地 Windows 压测数据只能作为开发参考。
|
||||
若目录已存在,只允许 clean checkout:
|
||||
|
||||
## 关键安全要求
|
||||
```bash
|
||||
sudo -u deploy git -C /opt/tiku-saas/source status --short
|
||||
sudo -u deploy git -C /opt/tiku-saas/source fetch origin main --prune
|
||||
sudo -u deploy git -C /opt/tiku-saas/source checkout main
|
||||
sudo -u deploy git -C /opt/tiku-saas/source merge --ff-only origin/main
|
||||
```
|
||||
|
||||
- 前端只保存 `supabasePublishableKey`,严禁出现 service role、数据库密码、短信密钥、支付私钥。
|
||||
- 自研业务 API 默认只接受 Supabase JWT 或迁移期受控 app session,不允许前端携带平台管理密钥。
|
||||
- API、worker、Supabase、Nginx 日志要开启轮转,避免磁盘被日志打满。
|
||||
- 数据库至少每日备份,正式放量前要完成一次恢复演练。
|
||||
- 支付回调、短信回调、对象存储回调必须使用 HTTPS 域名,并在 API 层校验签名和租户归属。
|
||||
- Supabase Studio 必须限制访问来源。
|
||||
### 3. 升级外置部署脚本
|
||||
|
||||
先备份,再安装兼容入口:
|
||||
|
||||
```bash
|
||||
sudo cp -a /opt/tiku-saas/bin/deploy.sh \
|
||||
/opt/tiku-saas/bin/deploy.sh.backup-$(date +%Y%m%d%H%M%S)
|
||||
|
||||
sudo install -o root -g deploy -m 0750 \
|
||||
/opt/tiku-saas/source/scripts/deploy/bin/deploy.sh \
|
||||
/opt/tiku-saas/bin/deploy.sh
|
||||
```
|
||||
|
||||
不要期待仓库 pull 自动更新 `/opt/tiku-saas/bin/deploy.sh`。
|
||||
|
||||
### 4. 更新配置
|
||||
|
||||
备份 `/etc/tiku-saas/deploy.env`、`api.env` 和 `worker.env`,再逐项 diff 模板,不要覆盖真实 secrets:
|
||||
|
||||
```bash
|
||||
diff -u /etc/tiku-saas/deploy.env \
|
||||
/opt/tiku-saas/source/scripts/deploy/env/deploy.env.example || true
|
||||
diff -u /etc/tiku-saas/api.env \
|
||||
/opt/tiku-saas/source/scripts/deploy/env/api.env.example || true
|
||||
diff -u /etc/tiku-saas/worker.env \
|
||||
/opt/tiku-saas/source/scripts/deploy/env/worker.env.example || true
|
||||
```
|
||||
|
||||
必须确认:
|
||||
|
||||
```text
|
||||
SOURCE_REPO_DIR=/opt/tiku-saas/source
|
||||
REPO_DIR=/opt/tiku-saas/repo
|
||||
RELEASES_DIR=/opt/tiku-saas/releases
|
||||
WWW_ROOT=/srv/tiku-saas/www
|
||||
WWW_RELEASES_DIR=/srv/tiku-saas/www-releases
|
||||
RUN_TARO_SUPPLY_CHAIN_AUDIT=true
|
||||
RUN_DB_READINESS=true
|
||||
RUN_LAUNCH_GATE=true
|
||||
SYSTEMD_UNITS="tiku-api.service tiku-workers.target"
|
||||
HEALTHCHECK_URL=http://127.0.0.1:8787/health
|
||||
```
|
||||
|
||||
API `DATABASE_URL` 必须使用 `tiku_api`,Worker 必须使用 `tiku_worker`。真实 env 的权限推荐为 `root:deploy 0640`。
|
||||
|
||||
Provider 密钥不要仅凭 env 模板判断“已经配置完成”。PNVS、OAuth、支付和租户级密钥以 `app_private.tenant_secrets` 及对应 Provider 配置为真实来源,必须通过仓库配置/诊断脚本和 production readiness 验证。对象存储及 Worker scanner 等进程级配置才由 API/Worker env 提供。
|
||||
|
||||
连接池要按进程总量预算:默认 9 个常驻 Worker 若每个 `DB_POOL_MAX=5`,仅 Worker 上限约 45 个连接;再加 API、timer/oneshot、Supabase 内部服务和运维连接。正式启用前应结合 PostgreSQL `max_connections` 和 PgBouncer 配额调整,而不是逐个进程孤立设置。
|
||||
|
||||
### 5. 升级 Worker 调度
|
||||
|
||||
不要使用通配复制后遗留旧 unit。显式安装当前清单:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tiku-worker.service 2>/dev/null || true
|
||||
sudo systemctl disable tiku-worker.service 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/tiku-worker.service
|
||||
|
||||
for unit in \
|
||||
tiku-api.service \
|
||||
tiku-worker@.service \
|
||||
tiku-worker-job@.service \
|
||||
tiku-worker-monthly-usage.service \
|
||||
tiku-worker-monthly-usage.timer \
|
||||
tiku-worker-platform-audit-alerts.timer \
|
||||
tiku-worker-platform-billing.timer \
|
||||
tiku-worker-platform-dunning.timer \
|
||||
tiku-worker-platform-usage.timer \
|
||||
tiku-worker-student-supervision.timer \
|
||||
tiku-workers.target
|
||||
do
|
||||
sudo install -o root -g root -m 0644 \
|
||||
"/opt/tiku-saas/source/scripts/deploy/systemd/$unit" \
|
||||
"/etc/systemd/system/$unit"
|
||||
done
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable tiku-api.service tiku-workers.target
|
||||
```
|
||||
|
||||
不要在新的应用 release 尚未构建并同步到 `/opt/tiku-saas/repo` 前启动 `tiku-workers.target`。
|
||||
|
||||
连续 Worker 为 CRM、commerce、provider bills、催缴通知、审计通知、assets、imports、public banks 和 exports;定时任务负责计费、用量、月结超额、催缴、审计告警和学习督导。
|
||||
|
||||
### 6. systemd 权限
|
||||
|
||||
部署器以 `deploy` 用户运行,但需要重启 `tiku-api.service` 和 `tiku-workers.target`。首次升级前检查:
|
||||
|
||||
```bash
|
||||
sudo -u deploy systemctl is-active tiku-api.service
|
||||
sudo -u deploy systemctl restart tiku-api.service
|
||||
```
|
||||
|
||||
第二条如果要求交互认证,部署会在切换阶段失败。不要给 `deploy` `NOPASSWD: ALL`。选择其一:
|
||||
|
||||
- 用受审 root wrapper 只允许 restart/is-active 这两个顶层 unit,并把 `RESTART_COMMAND` 指向 wrapper。
|
||||
- 配置精确的 PolicyKit 规则,只允许 deploy 管理本项目 unit。
|
||||
- 由 root 执行受控部署器,同时确保 clone/npm/release 文件所有权仍为 deploy,并重新验证脚本权限模型。
|
||||
|
||||
完成最小权限方案后,必须在非交互会话中验证。
|
||||
|
||||
### 7. 数据库、runtime config 与 evidence
|
||||
|
||||
按“新测试服务器”中的数据库步骤完成:
|
||||
|
||||
1. superuser runtime role/extension bootstrap。
|
||||
2. 为 `tiku_api/tiku_worker` 设置独立密码。
|
||||
3. 标准 migration role 应用 migration。
|
||||
4. `readiness:production` 和 `readiness:production:db`。
|
||||
|
||||
三端 runtime config 路径:
|
||||
|
||||
```text
|
||||
/etc/tiku-saas/runtime-config/h5-student.runtime-config.json
|
||||
/etc/tiku-saas/runtime-config/h5-tenant-admin.runtime-config.json
|
||||
/etc/tiku-saas/runtime-config/h5-platform-admin.runtime-config.json
|
||||
```
|
||||
|
||||
`tenantCode` 默认留空,由三个生产 Origin 解析租户;只有明确的固定租户预览/小程序模式才填写。
|
||||
|
||||
production evidence 必须绑定待发布 commit。兼容脚本默认读取:
|
||||
|
||||
```text
|
||||
/etc/tiku-saas/production-launch-evidence.json
|
||||
```
|
||||
|
||||
相对 artifact 路径从 evidence 所在目录解析,因此完整 bundle 应为:
|
||||
|
||||
```text
|
||||
/etc/tiku-saas/production-launch-evidence.json
|
||||
/etc/tiku-saas/launch-artifacts/*
|
||||
```
|
||||
|
||||
只复制 evidence JSON 而不复制 artifacts 会 fail closed。staging、本地 mock 或旧 commit 的 evidence 不能复用。
|
||||
|
||||
### 8. 宝塔/Nginx
|
||||
|
||||
宝塔服务器继续保留它管理的站点文件,不要直接套用普通 `/etc/nginx/sites-available` 命令。人工对照 `scripts/deploy/nginx/tjszsb.com.conf.example` 同步:
|
||||
|
||||
- `/srv/tiku-saas/www/{student,tenant-admin,platform-admin}` 三端路径。
|
||||
- SPA history fallback。
|
||||
- `runtime-config.json` `no-store`。
|
||||
- 带 hash 静态资源长期缓存。
|
||||
- API forwarded headers、body limit、超时和必要限流。
|
||||
- Supabase Gateway/Studio HTTPS 与 Studio 来源限制。
|
||||
|
||||
若宝塔站点根目录必须位于 `/www/wwwroot`,用受控软链接指向 `/srv/tiku-saas/www/*`,不要复制第二份静态文件形成漂移。
|
||||
|
||||
### 9. 正式执行
|
||||
|
||||
服务器需有 Node.js 20、npm、Git、rsync、curl、Supabase CLI,以及 Chrome/Chromium。浏览器不在标准路径时设置 `TARO_H5_SMOKE_BROWSER`。
|
||||
|
||||
先确认 source clean、evidence commit 和待发布 SHA 一致,再运行:
|
||||
|
||||
```bash
|
||||
sudo -u deploy /opt/tiku-saas/bin/deploy.sh
|
||||
```
|
||||
|
||||
如果本次数据库已独立迁移完成,保持:
|
||||
|
||||
```text
|
||||
RUN_DB_MIGRATIONS=false
|
||||
```
|
||||
|
||||
部署器在任何激活前完成候选构建和门禁;应用/Web 切换、服务重启、API health 或线上 H5 hash 失败会触发代码/Web回滚。
|
||||
|
||||
## 发布后验收
|
||||
|
||||
脚本只检查顶层 target active 和 API `/health`,发布后还必须人工运行:
|
||||
|
||||
```bash
|
||||
systemctl --failed --no-pager
|
||||
systemctl is-active tiku-api.service tiku-workers.target
|
||||
systemctl list-dependencies tiku-workers.target --no-pager
|
||||
systemctl list-timers 'tiku-worker-*' --all --no-pager
|
||||
systemctl status 'tiku-worker@*.service' --no-pager
|
||||
journalctl -u tiku-api.service -n 100 --no-pager
|
||||
journalctl -u 'tiku-worker@*.service' -n 200 --no-pager
|
||||
curl -fsS http://127.0.0.1:8787/health
|
||||
```
|
||||
|
||||
随后验证:
|
||||
|
||||
- 三个生产域名的 `index.html`、runtime config、登录和主入口。
|
||||
- `/api/tenant/resolve` 的 Origin/CORS。
|
||||
- 真实 Auth/JWKS、PNVS 短信、支付/退款回调、对象存储签名与扫描。
|
||||
- Worker backlog、失败重试、timer 下次执行时间和外部告警渠道。
|
||||
- 错误率、P95/P99、数据库连接池、慢 SQL、锁等待、磁盘和日志轮转。
|
||||
|
||||
## 回滚
|
||||
|
||||
自动回滚只覆盖应用代码和三端 Web:
|
||||
|
||||
```text
|
||||
/opt/tiku-saas/current
|
||||
/opt/tiku-saas/repo
|
||||
/srv/tiku-saas/www
|
||||
```
|
||||
|
||||
数据库 migration、对象存储写入、外部 Provider 状态和已产生业务事件不会自动回滚。数据库恢复必须使用发布前已验证的快照/备份,并由负责人单独决策。
|
||||
|
||||
手工代码/Web 回滚前,先记录失败 release 和日志,再将软链接切回上一 release、同步运行目录、重启服务并复核 API/H5。不要使用 `git reset --hard` 处理服务器运行目录。
|
||||
|
||||
## 安全检查清单
|
||||
|
||||
- [ ] 暴露 token 已吊销,服务器只读凭据已轮换。
|
||||
- [ ] 待发布 commit 已冻结,source checkout clean。
|
||||
- [ ] PostgreSQL、对象存储和 `/etc/tiku-saas` 已备份并验证可读。
|
||||
- [ ] `tiku_api/tiku_worker` bootstrap、独立密码和 migration role 已完成。
|
||||
- [ ] 三端 runtime config 只有公开字段,`tenantCode` 策略正确。
|
||||
- [ ] 旧 `tiku-worker.service` 已停止、禁用并删除。
|
||||
- [ ] 新 Worker units/timers/target 已安装,deploy 重启权限最小化。
|
||||
- [ ] Chrome/Chromium 可用于 33 项 H5 交互 smoke。
|
||||
- [ ] evidence commit、artifacts、hash 和人工 attestation 完整。
|
||||
- [ ] 宝塔/Nginx history fallback、缓存、CORS、CSP 和 forwarded headers 已复核。
|
||||
- [ ] 首个平台超管通过 Auth-bound bootstrap 创建并留有审计。
|
||||
- [ ] 发布后逐 Worker、timer、Provider、日志和监控验收完成。
|
||||
|
||||
## 参考
|
||||
|
||||
- Supabase self-hosting Docker: https://supabase.com/docs/guides/self-hosting/docker
|
||||
- Supabase reverse proxy and HTTPS: https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
|
||||
- Supabase Auth self-hosting config: https://supabase.com/docs/guides/self-hosting/auth/config
|
||||
- Supabase self-hosted S3 storage: https://supabase.com/docs/guides/self-hosting/self-hosted-s3
|
||||
- [根 README](../../README.md)
|
||||
- [生产地基基线](../../docs/refactor/production-foundation-baseline-20260712.md)
|
||||
- [Taro H5 部署](../../docs/refactor/taro-h5-deployment.md)
|
||||
- [上线 evidence 模板](../../docs/refactor/production-launch-evidence.template.json)
|
||||
- [Supabase self-hosting](https://supabase.com/docs/guides/self-hosting/docker)
|
||||
- [Supabase HTTPS reverse proxy](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
CONFIG_FILE="${CONFIG_FILE:-/etc/tiku-saas/deploy.env}"
|
||||
|
||||
@@ -16,35 +17,180 @@ source "$CONFIG_FILE"
|
||||
: "${GIT_REPO:?GIT_REPO is required}"
|
||||
: "${GIT_BRANCH:=main}"
|
||||
: "${APP_ROOT:=/opt/tiku-saas}"
|
||||
: "${SOURCE_REPO_DIR:=$APP_ROOT/source}"
|
||||
: "${REPO_DIR:=$APP_ROOT/repo}"
|
||||
: "${RELEASES_DIR:=$APP_ROOT/releases}"
|
||||
: "${CURRENT_LINK:=$APP_ROOT/current}"
|
||||
: "${WWW_ROOT:=/srv/tiku-saas/www}"
|
||||
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
|
||||
: "${RUNTIME_CONFIG_DIR:=/etc/tiku-saas/runtime-config}"
|
||||
: "${KEEP_RELEASES:=5}"
|
||||
: "${RUN_SECURITY_CHECKS:=true}"
|
||||
: "${RUN_RUNTIME_AUDIT:=true}"
|
||||
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
|
||||
: "${RUN_LAUNCH_GATE:=true}"
|
||||
: "${RUN_DB_MIGRATIONS:=false}"
|
||||
: "${RUN_DB_READINESS:=true}"
|
||||
: "${DATABASE_MIGRATION_URL:=}"
|
||||
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
|
||||
: "${API_ENV_FILE:=/etc/tiku-saas/api.env}"
|
||||
: "${RESTART_SERVICES:=true}"
|
||||
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
|
||||
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
|
||||
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
|
||||
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
|
||||
: "${NPM_REGISTRY:=https://registry.npmjs.org/}"
|
||||
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
|
||||
: "${NPM_FETCH_RETRIES:=5}"
|
||||
: "${NPM_FETCH_RETRY_MINTIMEOUT:=20000}"
|
||||
: "${NPM_FETCH_RETRY_MAXTIMEOUT:=120000}"
|
||||
: "${NPM_FETCH_TIMEOUT:=300000}"
|
||||
|
||||
LOCK_FILE="${LOCK_FILE:-/tmp/tiku-saas-deploy.lock}"
|
||||
mkdir -p "$APP_ROOT" "$WWW_ROOT/student" "$WWW_ROOT/tenant-admin" "$WWW_ROOT/platform-admin"
|
||||
export DATABASE_MIGRATION_URL
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Another deployment is already running." >&2
|
||||
exit 1
|
||||
fi
|
||||
LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"
|
||||
LOCK_ACQUIRED=false
|
||||
ASKPASS_FILE=""
|
||||
CANDIDATE_RELEASE=""
|
||||
PREVIOUS_APP_RELEASE=""
|
||||
PREVIOUS_WWW_RELEASE=""
|
||||
BOOTSTRAP_SERVICE_BACKUP=""
|
||||
ROLLBACK_ARMED=false
|
||||
APP_SWITCHED=false
|
||||
SERVICE_SYNC_STARTED=false
|
||||
WWW_SWITCH_STARTED=false
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[deploy][error] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
truthy() {
|
||||
case "${1:-}" in
|
||||
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
run_shell() {
|
||||
log "+ $*"
|
||||
bash -lc "$*"
|
||||
}
|
||||
|
||||
load_runtime_env() {
|
||||
[[ -r "$API_ENV_FILE" ]] || die "Missing API runtime config: $API_ENV_FILE"
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$API_ENV_FILE"
|
||||
set +a
|
||||
}
|
||||
|
||||
atomic_symlink() {
|
||||
local target="$1"
|
||||
local link="$2"
|
||||
local next_link="${link}.next.$$"
|
||||
|
||||
rm -f "$next_link"
|
||||
ln -s "$target" "$next_link"
|
||||
if [[ -L "$link" || ! -e "$link" ]]; then
|
||||
mv -Tf "$next_link" "$link"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$next_link"
|
||||
return 1
|
||||
}
|
||||
|
||||
restart_services() {
|
||||
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
|
||||
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required"
|
||||
|
||||
local unit
|
||||
for unit in $SYSTEMD_UNITS; do
|
||||
systemctl restart "$unit" || return 1
|
||||
done
|
||||
for unit in $SYSTEMD_UNITS; do
|
||||
systemctl is-active --quiet "$unit" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
|
||||
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
|
||||
|
||||
log "Waiting for API healthcheck: $HEALTHCHECK_URL"
|
||||
while (( SECONDS < deadline )); do
|
||||
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
||||
log "API healthcheck passed."
|
||||
return 0
|
||||
fi
|
||||
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
sync_service_repo() {
|
||||
local release="$1"
|
||||
[[ -d "$release" ]] || return 1
|
||||
if [[ -L "$REPO_DIR" ]]; then
|
||||
[[ "$(readlink -f "$REPO_DIR")" == "$(readlink -f "$release")" ]] \
|
||||
|| die "REPO_DIR symlink must resolve to the selected current release"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$REPO_DIR"
|
||||
rsync -a --delete --exclude .git "$release/" "$REPO_DIR/"
|
||||
}
|
||||
|
||||
rollback() {
|
||||
local failed=false
|
||||
ROLLBACK_ARMED=false
|
||||
|
||||
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
|
||||
log "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
|
||||
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_ROOT" || failed=true
|
||||
elif [[ "$WWW_SWITCH_STARTED" == "true" ]]; then
|
||||
rm -f "$WWW_ROOT" || failed=true
|
||||
fi
|
||||
|
||||
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
|
||||
log "Rolling application current link back to $PREVIOUS_APP_RELEASE"
|
||||
atomic_symlink "$PREVIOUS_APP_RELEASE" "$CURRENT_LINK" || failed=true
|
||||
elif [[ "$APP_SWITCHED" == "true" ]]; then
|
||||
rm -f "$CURRENT_LINK" || failed=true
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
|
||||
sync_service_repo "$PREVIOUS_APP_RELEASE" || failed=true
|
||||
elif [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$BOOTSTRAP_SERVICE_BACKUP" ]]; then
|
||||
log "Restoring pre-release service runtime"
|
||||
sync_service_repo "$BOOTSTRAP_SERVICE_BACKUP" || failed=true
|
||||
fi
|
||||
|
||||
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
|
||||
restart_services || failed=true
|
||||
healthcheck || failed=true
|
||||
fi
|
||||
[[ "$failed" == "false" ]]
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${ASKPASS_FILE:-}" && -f "$ASKPASS_FILE" ]]; then
|
||||
local status=$?
|
||||
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
|
||||
rollback || true
|
||||
fi
|
||||
if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then
|
||||
rm -f "$ASKPASS_FILE"
|
||||
fi
|
||||
if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then
|
||||
rmdir "$LOCK_DIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -53,14 +199,11 @@ prepare_git_auth() {
|
||||
export GIT_SSH_COMMAND
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "${GIT_USERNAME:-}" || -z "${GITEA_TOKEN:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
export GIT_USERNAME
|
||||
export GITEA_TOKEN
|
||||
|
||||
export GIT_USERNAME GITEA_TOKEN
|
||||
ASKPASS_FILE="$(mktemp)"
|
||||
chmod 700 "$ASKPASS_FILE"
|
||||
cat > "$ASKPASS_FILE" <<'ASKPASS'
|
||||
@@ -75,81 +218,225 @@ ASKPASS
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
}
|
||||
|
||||
prepare_git_auth
|
||||
|
||||
if [[ ! -d "$REPO_DIR/.git" ]]; then
|
||||
log "Cloning repository..."
|
||||
git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$REPO_DIR"
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
log "Fetching $GIT_BRANCH..."
|
||||
git fetch origin "$GIT_BRANCH" --prune
|
||||
git checkout "$GIT_BRANCH"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Repository has local changes. Refusing to deploy until the server checkout is clean." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git merge --ff-only "origin/$GIT_BRANCH"
|
||||
|
||||
CURRENT_SHA="$(git rev-parse --short=12 HEAD)"
|
||||
log "Deploying commit $CURRENT_SHA"
|
||||
|
||||
log "Installing dependencies with npm ci..."
|
||||
npm ci \
|
||||
--registry="$NPM_REGISTRY" \
|
||||
--fetch-retries="$NPM_FETCH_RETRIES" \
|
||||
--fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \
|
||||
--fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \
|
||||
--fetch-timeout="$NPM_FETCH_TIMEOUT"
|
||||
|
||||
if [[ "$RUN_SECURITY_CHECKS" == "true" ]]; then
|
||||
log "Running repository security scan..."
|
||||
npm run security:repo
|
||||
fi
|
||||
|
||||
if [[ "$RUN_LAUNCH_GATE" == "true" ]]; then
|
||||
log "Running production launch gate test..."
|
||||
node scripts/production-launch-gate-test.js
|
||||
fi
|
||||
|
||||
log "Building API and worker..."
|
||||
npm run build:api
|
||||
npm run build:worker
|
||||
|
||||
log "Building H5 portals..."
|
||||
npm run build:taro:h5:student
|
||||
npm run build:taro:h5:tenant
|
||||
npm run build:taro:h5:platform
|
||||
|
||||
log "Publishing H5 static assets..."
|
||||
rsync -a --delete apps/taro/dist/h5-student/ "$WWW_ROOT/student/"
|
||||
rsync -a --delete apps/taro/dist/h5-tenant-admin/ "$WWW_ROOT/tenant-admin/"
|
||||
rsync -a --delete apps/taro/dist/h5-platform-admin/ "$WWW_ROOT/platform-admin/"
|
||||
|
||||
install_runtime_config() {
|
||||
local source_file="$1"
|
||||
local target_dir="$2"
|
||||
if [[ ! -r "$source_file" ]]; then
|
||||
echo "Missing runtime config: $source_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -r "$source_file" ]] || die "Missing runtime config: $source_file"
|
||||
install -m 0644 "$source_file" "$target_dir/runtime-config.json"
|
||||
}
|
||||
|
||||
log "Installing H5 runtime config files..."
|
||||
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "$WWW_ROOT/student"
|
||||
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "$WWW_ROOT/tenant-admin"
|
||||
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "$WWW_ROOT/platform-admin"
|
||||
stage_candidate() {
|
||||
local release_name="$1"
|
||||
local staging="$RELEASES_DIR/.tmp-$release_name"
|
||||
|
||||
if [[ "$RESTART_SERVICES" == "true" ]]; then
|
||||
log "Restarting systemd services..."
|
||||
systemctl restart tiku-api.service
|
||||
systemctl restart tiku-worker.service
|
||||
systemctl --no-pager --full status tiku-api.service tiku-worker.service >/dev/null
|
||||
fi
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging"
|
||||
rsync -a --delete --exclude .git --exclude node_modules "$SOURCE_REPO_DIR/" "$staging/"
|
||||
if ! cp -al "$SOURCE_REPO_DIR/node_modules" "$staging/node_modules"; then
|
||||
rm -rf "$staging/node_modules"
|
||||
rsync -a "$SOURCE_REPO_DIR/node_modules/" "$staging/node_modules/"
|
||||
fi
|
||||
CANDIDATE_RELEASE="$RELEASES_DIR/$release_name"
|
||||
rm -rf "$CANDIDATE_RELEASE"
|
||||
mv "$staging" "$CANDIDATE_RELEASE"
|
||||
}
|
||||
|
||||
log "Deployment finished: $CURRENT_SHA"
|
||||
build_and_validate_candidate() {
|
||||
cd "$CANDIDATE_RELEASE"
|
||||
|
||||
if truthy "$RUN_SECURITY_CHECKS"; then
|
||||
log "Running repository security scan against the candidate..."
|
||||
GIT_DIR="$SOURCE_REPO_DIR/.git" GIT_WORK_TREE="$CANDIDATE_RELEASE" npm run security:repo
|
||||
fi
|
||||
|
||||
log "Building API, worker and H5 portals..."
|
||||
npm run build:api
|
||||
npm run build:worker
|
||||
npm run check:taro
|
||||
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
|
||||
npm run audit:taro:supply-chain
|
||||
fi
|
||||
npm run build:taro:h5:student
|
||||
npm run build:taro:h5:tenant
|
||||
npm run build:taro:h5:platform
|
||||
|
||||
log "Installing candidate H5 runtime configs..."
|
||||
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "apps/taro/dist/h5-student"
|
||||
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "apps/taro/dist/h5-tenant-admin"
|
||||
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "apps/taro/dist/h5-platform-admin"
|
||||
|
||||
log "Validating candidate H5 artifacts before touching $WWW_ROOT..."
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
|
||||
npm run manifest:taro:h5 -- --require-dist --require-runtime-config
|
||||
npm run smoke:taro:h5
|
||||
TARO_H5_INTERACTION_OUTPUT_DIR="${TARO_H5_INTERACTION_OUTPUT_DIR:-/tmp/tiku-h5-smoke}" npm run smoke:taro:h5:interaction
|
||||
|
||||
if truthy "$RUN_RUNTIME_AUDIT"; then
|
||||
NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
|
||||
fi
|
||||
|
||||
load_runtime_env
|
||||
log "Running production environment readiness before the database migration step..."
|
||||
npm run readiness:production
|
||||
|
||||
if truthy "$RUN_DB_MIGRATIONS"; then
|
||||
log "Applying Supabase database migrations..."
|
||||
run_shell "$DB_MIGRATION_COMMAND"
|
||||
fi
|
||||
|
||||
if truthy "$RUN_DB_READINESS"; then
|
||||
log "Running production database readiness after the optional migration step..."
|
||||
npm run readiness:production:db
|
||||
fi
|
||||
|
||||
if truthy "$RUN_LAUNCH_GATE"; then
|
||||
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
|
||||
log "Running production launch gate against the candidate..."
|
||||
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
|
||||
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
|
||||
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
|
||||
fi
|
||||
}
|
||||
|
||||
verify_live_h5_release() {
|
||||
truthy "$RUN_LAUNCH_GATE" || return 0
|
||||
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
|
||||
|
||||
log "Verifying the activated H5 release against production URLs..."
|
||||
(
|
||||
cd "$CANDIDATE_RELEASE"
|
||||
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
|
||||
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
|
||||
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
|
||||
)
|
||||
}
|
||||
|
||||
stage_www_candidate() {
|
||||
local release_name="$1"
|
||||
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
|
||||
local final="$WWW_RELEASES_DIR/$release_name"
|
||||
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
|
||||
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-student/" "$staging/student/"
|
||||
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
|
||||
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
|
||||
rm -rf "$final"
|
||||
mv "$staging" "$final"
|
||||
printf '%s\n' "$final"
|
||||
}
|
||||
|
||||
switch_www_release() {
|
||||
local candidate_www="$1"
|
||||
WWW_SWITCH_STARTED=true
|
||||
|
||||
if [[ -L "$WWW_ROOT" ]]; then
|
||||
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_ROOT")"
|
||||
elif [[ -d "$WWW_ROOT" ]]; then
|
||||
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-www-$(date +%Y%m%d%H%M%S)"
|
||||
mv "$WWW_ROOT" "$PREVIOUS_WWW_RELEASE"
|
||||
elif [[ -e "$WWW_ROOT" ]]; then
|
||||
die "WWW_ROOT exists but is not a directory or symlink: $WWW_ROOT"
|
||||
fi
|
||||
atomic_symlink "$candidate_www" "$WWW_ROOT"
|
||||
}
|
||||
|
||||
prune_releases() {
|
||||
local directory="$1"
|
||||
[[ "$KEEP_RELEASES" =~ ^[0-9]+$ ]] || return 0
|
||||
(( KEEP_RELEASES > 0 )) || return 0
|
||||
|
||||
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
|
||||
| sort -r \
|
||||
| tail -n +"$((KEEP_RELEASES + 1))" \
|
||||
| while IFS= read -r old_release; do
|
||||
[[ "$old_release" == "$CANDIDATE_RELEASE" || "$old_release" == "$PREVIOUS_APP_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]] && continue
|
||||
rm -rf "$old_release"
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
require_command git
|
||||
require_command npm
|
||||
require_command rsync
|
||||
require_command curl
|
||||
[[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT"
|
||||
case "${WWW_RELEASES_DIR%/}/" in
|
||||
"${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;;
|
||||
esac
|
||||
[[ "$SOURCE_REPO_DIR" != "$REPO_DIR" ]] || die "SOURCE_REPO_DIR must be separate from the systemd REPO_DIR"
|
||||
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
|
||||
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
|
||||
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true"
|
||||
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|
||||
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
|
||||
truthy "$RUN_DB_READINESS" \
|
||||
|| die "Production deployment requires RUN_DB_READINESS=true before launch gate"
|
||||
if truthy "$RUN_DB_MIGRATIONS"; then
|
||||
[[ -n "$DATABASE_MIGRATION_URL" ]] \
|
||||
|| die "Production database migrations require a separate DATABASE_MIGRATION_URL"
|
||||
fi
|
||||
|
||||
mkdir -p "$APP_ROOT" "$RELEASES_DIR" "$WWW_RELEASES_DIR"
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || die "Another deployment is already running: $LOCK_DIR"
|
||||
LOCK_ACQUIRED=true
|
||||
prepare_git_auth
|
||||
|
||||
if [[ ! -d "$SOURCE_REPO_DIR/.git" ]]; then
|
||||
log "Cloning source repository..."
|
||||
git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$SOURCE_REPO_DIR"
|
||||
fi
|
||||
|
||||
log "Fetching $GIT_BRANCH..."
|
||||
if [[ -n "$(git -C "$SOURCE_REPO_DIR" status --porcelain --untracked-files=normal)" ]]; then
|
||||
die "Source repository has local changes; refusing to deploy."
|
||||
fi
|
||||
git -C "$SOURCE_REPO_DIR" fetch origin "$GIT_BRANCH" --prune
|
||||
git -C "$SOURCE_REPO_DIR" checkout "$GIT_BRANCH"
|
||||
git -C "$SOURCE_REPO_DIR" merge --ff-only "origin/$GIT_BRANCH"
|
||||
|
||||
local commit release_name
|
||||
commit="$(git -C "$SOURCE_REPO_DIR" rev-parse --short=12 HEAD)"
|
||||
release_name="$(date +%Y%m%d%H%M%S)-$commit"
|
||||
log "Preparing candidate commit $commit"
|
||||
|
||||
log "Installing locked dependencies in the source checkout..."
|
||||
npm --prefix "$SOURCE_REPO_DIR" ci \
|
||||
--workspaces \
|
||||
--include-workspace-root \
|
||||
--include=dev \
|
||||
--registry="$NPM_REGISTRY" \
|
||||
--fetch-retries="$NPM_FETCH_RETRIES" \
|
||||
--fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \
|
||||
--fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \
|
||||
--fetch-timeout="$NPM_FETCH_TIMEOUT"
|
||||
|
||||
stage_candidate "$release_name"
|
||||
build_and_validate_candidate
|
||||
local candidate_www
|
||||
candidate_www="$(stage_www_candidate "$release_name")"
|
||||
|
||||
if [[ -L "$CURRENT_LINK" ]]; then
|
||||
PREVIOUS_APP_RELEASE="$(readlink -f "$CURRENT_LINK")"
|
||||
elif [[ -d "$REPO_DIR" ]]; then
|
||||
BOOTSTRAP_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
|
||||
mkdir -p "$BOOTSTRAP_SERVICE_BACKUP"
|
||||
rsync -a --delete --exclude .git "$REPO_DIR/" "$BOOTSTRAP_SERVICE_BACKUP/"
|
||||
fi
|
||||
ROLLBACK_ARMED=true
|
||||
atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK" || die "Failed to switch application current release"
|
||||
APP_SWITCHED=true
|
||||
SERVICE_SYNC_STARTED=true
|
||||
sync_service_repo "$CANDIDATE_RELEASE"
|
||||
restart_services
|
||||
healthcheck
|
||||
switch_www_release "$candidate_www" || die "Failed to switch Web release"
|
||||
verify_live_h5_release
|
||||
ROLLBACK_ARMED=false
|
||||
|
||||
prune_releases "$RELEASES_DIR"
|
||||
prune_releases "$WWW_RELEASES_DIR"
|
||||
log "Deployment finished: $commit"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
37
scripts/deploy/env/api.env.example
vendored
37
scripts/deploy/env/api.env.example
vendored
@@ -1,12 +1,32 @@
|
||||
# Copy to /etc/tiku-saas/api.env and chmod 600.
|
||||
# Copy to /etc/tiku-saas/api.env, chown root:deploy, and chmod 640.
|
||||
# This file is read by systemd. Do not commit the real file.
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=8787
|
||||
API_HEADERS_TIMEOUT_MS=15000
|
||||
API_REQUEST_TIMEOUT_MS=120000
|
||||
API_KEEP_ALIVE_TIMEOUT_MS=5000
|
||||
API_SHUTDOWN_GRACE_PERIOD_MS=30000
|
||||
API_MAX_REQUESTS_PER_SOCKET=1000
|
||||
|
||||
DATABASE_URL=postgresql://tiku_app:replace-with-password@127.0.0.1:5432/postgres
|
||||
DATABASE_URL=postgresql://tiku_api:replace-with-password@127.0.0.1:5432/postgres
|
||||
DB_EXPECTED_RUNTIME_ROLE=tiku_api
|
||||
DB_POOL_MAX=10
|
||||
DB_CONNECTION_TIMEOUT_MS=5000
|
||||
DB_QUERY_TIMEOUT_MS=35000
|
||||
DB_STATEMENT_TIMEOUT_MS=30000
|
||||
DB_LOCK_TIMEOUT_MS=5000
|
||||
DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000
|
||||
DB_IDLE_TIMEOUT_MS=30000
|
||||
DB_POOL_MAX_USES=7500
|
||||
DB_POOL_MAX_LIFETIME_SECONDS=1800
|
||||
DB_APPLICATION_NAME=tiku-api
|
||||
DEFAULT_TENANT_SLUG=master
|
||||
CORS_ORIGIN=https://app.tjszsb.com,https://admin.tjszsb.com,https://console.tjszsb.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
@@ -21,6 +41,11 @@ PLATFORM_ADMIN_API_KEY=replace-with-strong-random-platform-admin-key
|
||||
# Production SMS verification uses aliyun-pnvs. Traditional aliyun/tencent adapters are compatibility paths only.
|
||||
# Tenant-level SMS AccessKey/SecretKey live in app_private.tenant_secrets, not in this env file.
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_SMS_COOLDOWN_SECONDS=60
|
||||
AUTH_SMS_TENANT_DAILY_LIMIT=20000
|
||||
AUTH_SMS_PHONE_DAILY_LIMIT=10
|
||||
AUTH_SMS_IP_HOURLY_LIMIT=120
|
||||
AUTH_SMS_DEVICE_HOURLY_LIMIT=10
|
||||
|
||||
WECHAT_MINIAPP_APP_ID=replace-with-miniapp-app-id
|
||||
WECHAT_MINIAPP_APP_SECRET=replace-with-miniapp-app-secret
|
||||
@@ -43,14 +68,16 @@ ALIPAY_PUBLIC_KEY=replace-with-alipay-public-key
|
||||
ALIPAY_NOTIFY_URL=https://api.tjszsb.com/api/commerce/webhooks/alipay
|
||||
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=replace-with-bucket
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=oss-cn-beijing
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
|
||||
ALIYUN_OSS_BUCKET=replace-with-bucket
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=replace-with-access-key-id
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=replace-with-access-key-secret
|
||||
ASSET_SIGNING_SECRET=replace-with-strong-random-asset-secret
|
||||
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
ASSET_SECURITY_SCAN_ENDPOINT=https://replace-with-security-scanner.example.com/scan
|
||||
ASSET_SECURITY_SCAN_TOKEN=replace-with-scanner-token
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://replace-with-security-scanner.example.com/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=replace-with-strong-scanner-token
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||||
|
||||
30
scripts/deploy/env/deploy.env.example
vendored
30
scripts/deploy/env/deploy.env.example
vendored
@@ -9,16 +9,42 @@ GIT_USERNAME=replace-with-readonly-deploy-user
|
||||
GITEA_TOKEN=replace-with-rotated-readonly-token
|
||||
|
||||
APP_ROOT=/opt/tiku-saas
|
||||
# The source checkout is isolated from the systemd runtime directory. This keeps
|
||||
# candidate builds and npm ci from mutating the currently running release.
|
||||
SOURCE_REPO_DIR=/opt/tiku-saas/source
|
||||
REPO_DIR=/opt/tiku-saas/repo
|
||||
RELEASES_DIR=/opt/tiku-saas/releases
|
||||
WWW_ROOT=/srv/tiku-saas/www
|
||||
WWW_RELEASES_DIR=/srv/tiku-saas/www-releases
|
||||
RUNTIME_CONFIG_DIR=/etc/tiku-saas/runtime-config
|
||||
|
||||
RUN_SECURITY_CHECKS=true
|
||||
RUN_RUNTIME_AUDIT=true
|
||||
RUN_TARO_SUPPLY_CHAIN_AUDIT=true
|
||||
RUN_LAUNCH_GATE=true
|
||||
RESTART_SERVICES=true
|
||||
PRODUCTION_LAUNCH_EVIDENCE=/etc/tiku-saas/production-launch-evidence.json
|
||||
|
||||
# Use https://registry.npmmirror.com on mainland China servers if npmjs times out.
|
||||
# Database migrations are opt-in and run only after the environment/build gates.
|
||||
# The deploy script always reruns DB readiness after this step and before launch:gate.
|
||||
RUN_DB_MIGRATIONS=false
|
||||
RUN_DB_READINESS=true
|
||||
# Do not assign this in the file; inject DATABASE_MIGRATION_URL for the standard
|
||||
# migration role from a secret manager immediately before deployment.
|
||||
DB_MIGRATION_COMMAND='supabase db push --db-url "$DATABASE_MIGRATION_URL"'
|
||||
API_ENV_FILE=/etc/tiku-saas/api.env
|
||||
RESTART_SERVICES=true
|
||||
SYSTEMD_UNITS="tiku-api.service tiku-workers.target"
|
||||
HEALTHCHECK_URL=http://127.0.0.1:8787/health
|
||||
HEALTHCHECK_TIMEOUT_SECONDS=60
|
||||
HEALTHCHECK_INTERVAL_SECONDS=2
|
||||
KEEP_RELEASES=5
|
||||
|
||||
# Package downloads may use a mirror when npmjs times out.
|
||||
# The deploy script always installs workspace dev dependencies and runs lifecycle
|
||||
# scripts because the locked Taro toolchain and reviewed Input patch require both.
|
||||
NPM_REGISTRY=https://registry.npmjs.org/
|
||||
# npm audit must use a registry with the audit API; npmmirror does not provide it.
|
||||
NPM_AUDIT_REGISTRY=https://registry.npmjs.org/
|
||||
NPM_FETCH_RETRIES=5
|
||||
NPM_FETCH_RETRY_MINTIMEOUT=20000
|
||||
NPM_FETCH_RETRY_MAXTIMEOUT=120000
|
||||
|
||||
126
scripts/deploy/env/worker.env.example
vendored
126
scripts/deploy/env/worker.env.example
vendored
@@ -1,32 +1,124 @@
|
||||
# Copy to /etc/tiku-saas/worker.env and chmod 600.
|
||||
# This file is read by systemd. Do not commit the real file.
|
||||
# This file is read by every worker systemd unit. Do not commit the real file.
|
||||
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://tiku_app:replace-with-password@127.0.0.1:5432/postgres
|
||||
|
||||
SUPABASE_URL=https://supabase.tjszsb.com
|
||||
DATABASE_URL=postgresql://tiku_worker:replace-with-password@127.0.0.1:5432/postgres
|
||||
DB_EXPECTED_RUNTIME_ROLE=tiku_worker
|
||||
# There are nine continuous worker processes; budget total PostgreSQL connections accordingly.
|
||||
DB_POOL_MAX=5
|
||||
DB_CONNECTION_TIMEOUT_MS=5000
|
||||
DB_QUERY_TIMEOUT_MS=35000
|
||||
DB_STATEMENT_TIMEOUT_MS=30000
|
||||
DB_LOCK_TIMEOUT_MS=5000
|
||||
DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000
|
||||
DB_IDLE_TIMEOUT_MS=30000
|
||||
DB_POOL_MAX_USES=7500
|
||||
DB_POOL_MAX_LIFETIME_SECONDS=1800
|
||||
DB_APPLICATION_NAME=tiku-worker
|
||||
|
||||
# Object storage settings read by apps/worker/src/config.ts.
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=replace-with-bucket
|
||||
STORAGE_PUBLIC_BASE_URL=https://replace-with-public-assets.example.com
|
||||
STORAGE_MAX_UPLOAD_BYTES=524288000
|
||||
STORAGE_ALLOWED_MIME_PREFIXES=image/,video/,audio/
|
||||
STORAGE_ALLOWED_MIME_TYPES=application/pdf,application/json,application/zip,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/markdown,text/csv
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
|
||||
ALIYUN_OSS_REGION=oss-cn-beijing
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
|
||||
ALIYUN_OSS_BUCKET=replace-with-bucket
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=replace-with-access-key-id
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=replace-with-access-key-secret
|
||||
ASSET_SIGNING_SECRET=replace-with-strong-random-asset-secret
|
||||
ALIYUN_OSS_STS_TOKEN=
|
||||
ALIYUN_OSS_INTERNAL=false
|
||||
|
||||
# Production asset scans are fail-closed and use the WORKER_* names below.
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://replace-with-security-scanner.example.com/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=replace-with-strong-scanner-token
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
ASSET_SECURITY_SCAN_ENDPOINT=https://replace-with-security-scanner.example.com/scan
|
||||
ASSET_SECURITY_SCAN_TOKEN=replace-with-scanner-token
|
||||
|
||||
WECHAT_PAY_MCH_ID=replace-with-merchant-id
|
||||
WECHAT_PAY_APP_ID=replace-with-pay-app-id
|
||||
WECHAT_PAY_API_V3_KEY=replace-with-api-v3-key
|
||||
WECHAT_PAY_PRIVATE_KEY=replace-with-private-key-path-or-kms-id
|
||||
# Continuous queue consumers managed by tiku-worker@.service.
|
||||
WORKER_CRM_BATCH_SIZE=20
|
||||
WORKER_CRM_POLL_INTERVAL_MS=10000
|
||||
WORKER_CRM_MAX_ATTEMPTS=5
|
||||
WORKER_CRM_BACKOFF_SECONDS=5,30,120,600,1800
|
||||
WORKER_CRM_REQUEST_TIMEOUT_MS=10000
|
||||
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
|
||||
|
||||
ALIPAY_APP_ID=replace-with-alipay-app-id
|
||||
ALIPAY_APP_PRIVATE_KEY=replace-with-private-key-path-or-kms-id
|
||||
ALIPAY_PUBLIC_KEY=replace-with-alipay-public-key
|
||||
WORKER_COMMERCE_BATCH_SIZE=20
|
||||
WORKER_COMMERCE_POLL_INTERVAL_MS=30000
|
||||
WORKER_COMMERCE_MIN_AGE_SECONDS=300
|
||||
WORKER_COMMERCE_REQUEST_TIMEOUT_MS=10000
|
||||
|
||||
CRM_WEBHOOK_TIMEOUT_MS=5000
|
||||
WORKER_POLL_INTERVAL_MS=5000
|
||||
WORKER_PROVIDER_BILL_BATCH_SIZE=5
|
||||
WORKER_PROVIDER_BILL_POLL_INTERVAL_MS=60000
|
||||
WORKER_PROVIDER_BILL_ID=provider-bills-prod-1
|
||||
WORKER_PROVIDER_BILL_CLAIM_STALE_SECONDS=900
|
||||
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE=50
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS=30000
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS=5
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
|
||||
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE=50
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS=30000
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS=5
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
|
||||
|
||||
WORKER_ASSET_BATCH_SIZE=50
|
||||
WORKER_ASSET_POLL_INTERVAL_MS=30000
|
||||
WORKER_ASSET_MIN_AGE_SECONDS=300
|
||||
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
|
||||
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
||||
|
||||
WORKER_IMPORT_BATCH_SIZE=5
|
||||
WORKER_IMPORT_POLL_INTERVAL_MS=10000
|
||||
WORKER_IMPORT_ID=imports-prod-1
|
||||
WORKER_IMPORT_LEASE_SECONDS=120
|
||||
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
|
||||
WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800
|
||||
|
||||
WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5
|
||||
WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS=60000
|
||||
WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT=1000
|
||||
WORKER_PUBLIC_BANK_SYNC_ID=public-banks-prod-1
|
||||
WORKER_PUBLIC_BANK_SYNC_CLAIM_STALE_SECONDS=900
|
||||
|
||||
WORKER_EXPORT_BATCH_SIZE=5
|
||||
WORKER_EXPORT_POLL_INTERVAL_MS=10000
|
||||
WORKER_EXPORT_ID=exports-prod-1
|
||||
WORKER_EXPORT_BACKOFF_SECONDS=30,120,600,1800
|
||||
EXPORT_LOCAL_STORAGE_ROOT=/srv/tiku-saas/data/exports
|
||||
EXPORT_PDF_FONT_PATH=
|
||||
|
||||
# Periodic jobs managed by tiku-worker-job@.service and timer units.
|
||||
WORKER_PLATFORM_BILLING_BATCH_SIZE=50
|
||||
WORKER_PLATFORM_BILLING_DAYS_AHEAD=45
|
||||
WORKER_PLATFORM_BILLING_DUE_DAYS=15
|
||||
WORKER_PLATFORM_BILLING_ID=platform-billing-prod-1
|
||||
|
||||
# Leave month overrides empty for normal timers. Use CLI --month for backfills.
|
||||
WORKER_PLATFORM_USAGE_BATCH_SIZE=100
|
||||
WORKER_PLATFORM_USAGE_ID=platform-usage-prod-1
|
||||
WORKER_PLATFORM_USAGE_MONTH=
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE=100
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_ID=platform-usage-overage-prod-1
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_MONTH=
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS=15
|
||||
|
||||
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
|
||||
WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1
|
||||
|
||||
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200
|
||||
WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-prod-1
|
||||
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS=14
|
||||
|
||||
WORKER_STUDENT_SUPERVISION_BATCH_SIZE=20
|
||||
WORKER_STUDENT_SUPERVISION_ID=student-supervision-prod-1
|
||||
WORKER_STUDENT_SUPERVISION_CLAIM_STALE_SECONDS=900
|
||||
|
||||
@@ -6,6 +6,9 @@ map $http_upgrade $connection_upgrade {
|
||||
'' close;
|
||||
}
|
||||
|
||||
limit_req_zone $binary_remote_addr zone=tiku_auth:10m rate=50r/s;
|
||||
limit_req_zone $binary_remote_addr zone=tiku_sms_send:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.tjszsb.com;
|
||||
@@ -90,12 +93,45 @@ server {
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location = /api/auth/sms/send {
|
||||
limit_req zone=tiku_sms_send burst=30 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host "";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Overwrite, rather than append to, any client-supplied forwarding chain.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
location ^~ /api/auth/ {
|
||||
limit_req zone=tiku_auth burst=100 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host "";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# H5 tenant resolution trusts the browser Origin header. Do not accept a client-supplied forwarded host.
|
||||
proxy_set_header X-Forwarded-Host "";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"apiBaseUrl": "https://api.tjszsb.com",
|
||||
"supabaseUrl": "https://supabase.tjszsb.com",
|
||||
"supabasePublishableKey": "replace-with-supabase-publishable-key",
|
||||
"tenantCode": "master"
|
||||
"tenantCode": ""
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"apiBaseUrl": "https://api.tjszsb.com",
|
||||
"supabaseUrl": "https://supabase.tjszsb.com",
|
||||
"supabasePublishableKey": "replace-with-supabase-publishable-key",
|
||||
"tenantCode": "master"
|
||||
"tenantCode": ""
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"apiBaseUrl": "https://api.tjszsb.com",
|
||||
"supabaseUrl": "https://supabase.tjszsb.com",
|
||||
"supabasePublishableKey": "replace-with-supabase-publishable-key",
|
||||
"tenantCode": "master"
|
||||
"tenantCode": ""
|
||||
}
|
||||
|
||||
188
scripts/deploy/sql/bootstrap-backend-runtime-roles.sql
Normal file
188
scripts/deploy/sql/bootstrap-backend-runtime-roles.sql
Normal file
@@ -0,0 +1,188 @@
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from pg_roles where rolname = 'tiku_api') then
|
||||
create role tiku_api login;
|
||||
end if;
|
||||
if not exists (select 1 from pg_roles where rolname = 'tiku_worker') then
|
||||
create role tiku_worker login;
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
alter role tiku_api
|
||||
login nosuperuser noinherit nocreatedb nocreaterole noreplication bypassrls;
|
||||
|
||||
alter role tiku_worker
|
||||
login nosuperuser noinherit nocreatedb nocreaterole noreplication bypassrls;
|
||||
|
||||
create schema if not exists extensions;
|
||||
revoke create on schema extensions from public, tiku_api, tiku_worker;
|
||||
grant usage on schema extensions to tiku_api, tiku_worker;
|
||||
|
||||
-- Install or relocate required extensions before normal migrations. Otherwise
|
||||
-- citext/ltree can be created later by supabase_admin with PostgreSQL's default
|
||||
-- PUBLIC EXECUTE and the migration role cannot close that RPC surface.
|
||||
do $$
|
||||
declare
|
||||
extension_name name;
|
||||
extension_schema name;
|
||||
extension_relocatable boolean;
|
||||
begin
|
||||
foreach extension_name in array array[
|
||||
'pgcrypto'::name,
|
||||
'citext'::name,
|
||||
'ltree'::name,
|
||||
'pg_trgm'::name
|
||||
]
|
||||
loop
|
||||
select namespace.nspname, extension.extrelocatable
|
||||
into extension_schema, extension_relocatable
|
||||
from pg_extension extension
|
||||
join pg_namespace namespace on namespace.oid = extension.extnamespace
|
||||
where extension.extname = extension_name;
|
||||
|
||||
if not found then
|
||||
execute format('create extension %I with schema extensions', extension_name);
|
||||
elsif extension_schema <> 'extensions' then
|
||||
if not extension_relocatable then
|
||||
raise exception 'Required extension % cannot be relocated from schema %', extension_name, extension_schema;
|
||||
end if;
|
||||
execute format('alter extension %I set schema extensions', extension_name);
|
||||
end if;
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
alter role tiku_api set search_path = pg_catalog, public, extensions;
|
||||
alter role tiku_worker set search_path = pg_catalog, public, extensions;
|
||||
|
||||
-- Official Supabase images can preinstall relocatable extensions such as
|
||||
-- citext and ltree in public. Their functions are owned by supabase_admin, so
|
||||
-- the normal migration role cannot remove PostgreSQL's default PUBLIC EXECUTE.
|
||||
-- Seal that inherited RPC surface while a real superuser is available.
|
||||
do $$
|
||||
declare
|
||||
required_role name;
|
||||
begin
|
||||
foreach required_role in array array['anon'::name, 'authenticated'::name]
|
||||
loop
|
||||
if not exists (select 1 from pg_roles where rolname = required_role) then
|
||||
raise exception 'Required Supabase Data API role % is missing', required_role;
|
||||
end if;
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
revoke execute on all functions in schema public
|
||||
from public, anon, authenticated, tiku_api, tiku_worker;
|
||||
revoke execute on all functions in schema extensions
|
||||
from public, anon, authenticated, tiku_api, tiku_worker;
|
||||
|
||||
-- Supabase's internal services use dedicated trusted database roles. Preserve
|
||||
-- their extension execution when those roles exist, without reopening the
|
||||
-- surface to the client-facing anon/authenticated roles.
|
||||
do $$
|
||||
declare
|
||||
trusted_role name;
|
||||
begin
|
||||
foreach trusted_role in array array[
|
||||
'postgres'::name,
|
||||
'service_role'::name,
|
||||
'dashboard_user'::name,
|
||||
'supabase_auth_admin'::name,
|
||||
'supabase_storage_admin'::name,
|
||||
'supabase_realtime_admin'::name,
|
||||
'supabase_functions_admin'::name
|
||||
]
|
||||
loop
|
||||
if exists (select 1 from pg_roles where rolname = trusted_role) then
|
||||
execute format('grant execute on all functions in schema extensions to %I', trusted_role);
|
||||
end if;
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
-- citext and ltree operators, casts and tree helpers call extension-owned
|
||||
-- functions. pg_trgm index support is similarly needed by backend search.
|
||||
-- Backend roles receive only those pure type/search helpers; pgcrypto stays
|
||||
-- unavailable and Data API client roles receive no extension execution.
|
||||
do $$
|
||||
declare
|
||||
extension_function record;
|
||||
begin
|
||||
for extension_function in
|
||||
select procedure_row.oid::regprocedure::text as signature
|
||||
from pg_proc procedure_row
|
||||
join pg_depend dependency
|
||||
on dependency.classid = 'pg_proc'::regclass
|
||||
and dependency.objid = procedure_row.oid
|
||||
and dependency.refclassid = 'pg_extension'::regclass
|
||||
and dependency.deptype = 'e'
|
||||
join pg_extension extension on extension.oid = dependency.refobjid
|
||||
where extension.extname in ('citext', 'ltree', 'pg_trgm')
|
||||
loop
|
||||
execute format(
|
||||
'revoke execute on function %s from public, anon, authenticated, tiku_api, tiku_worker',
|
||||
extension_function.signature
|
||||
);
|
||||
execute format(
|
||||
'grant execute on function %s to tiku_api, tiku_worker',
|
||||
extension_function.signature
|
||||
);
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
-- Extension upgrades may add more public functions under the extension owner.
|
||||
-- PostgreSQL's function default ACL is global, so this must not use IN SCHEMA.
|
||||
do $$
|
||||
declare
|
||||
owner_name name;
|
||||
begin
|
||||
for owner_name in
|
||||
select bootstrap_owner.owner_name
|
||||
from (
|
||||
select current_user::name as owner_name
|
||||
union
|
||||
select distinct owner_role.rolname
|
||||
from pg_proc function_row
|
||||
join pg_namespace namespace on namespace.oid = function_row.pronamespace
|
||||
join pg_roles owner_role on owner_role.oid = function_row.proowner
|
||||
where namespace.nspname in ('public', 'extensions')
|
||||
) bootstrap_owner
|
||||
loop
|
||||
execute format(
|
||||
'alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker',
|
||||
owner_name
|
||||
);
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
-- Remove all inherited or SET ROLE paths. Runtime roles receive object ACLs
|
||||
-- directly from the normal migration and never inherit another database role.
|
||||
do $$
|
||||
declare
|
||||
runtime_role name;
|
||||
parent_role name;
|
||||
begin
|
||||
foreach runtime_role in array array['tiku_api'::name, 'tiku_worker'::name]
|
||||
loop
|
||||
for parent_role in
|
||||
select parent.rolname
|
||||
from pg_auth_members membership
|
||||
join pg_roles member on member.oid = membership.member
|
||||
join pg_roles parent on parent.oid = membership.roleid
|
||||
where member.rolname = runtime_role
|
||||
loop
|
||||
execute format('revoke %I from %I', parent_role, runtime_role);
|
||||
end loop;
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
comment on role tiku_api is
|
||||
'Trusted API-only runtime. Cluster attributes are provisioned by the database administrator; object ACLs are managed by migration 202607120013.';
|
||||
|
||||
comment on role tiku_worker is
|
||||
'Trusted background-worker runtime. Cluster attributes are provisioned by the database administrator; object ACLs are managed by migration 202607120013.';
|
||||
@@ -13,7 +13,7 @@ ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/api/dist/apps/api/src/server.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
TimeoutStopSec=40
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
22
scripts/deploy/systemd/tiku-worker-job@.service
Normal file
22
scripts/deploy/systemd/tiku-worker-job@.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=tiku-supabase periodic worker job (%i)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/tiku-saas/repo
|
||||
EnvironmentFile=/etc/tiku-saas/worker.env
|
||||
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job %i
|
||||
TimeoutStartSec=1h
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/srv/tiku-saas /opt/tiku-saas/repo
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
LockPersonality=true
|
||||
23
scripts/deploy/systemd/tiku-worker-monthly-usage.service
Normal file
23
scripts/deploy/systemd/tiku-worker-monthly-usage.service
Normal file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Collect and invoice previous-month SaaS usage
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/tiku-saas/repo
|
||||
EnvironmentFile=/etc/tiku-saas/worker.env
|
||||
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job platform-usage --month previous
|
||||
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job platform-usage-overage --month previous
|
||||
TimeoutStartSec=3h
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/srv/tiku-saas /opt/tiku-saas/repo
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
LockPersonality=true
|
||||
12
scripts/deploy/systemd/tiku-worker-monthly-usage.timer
Normal file
12
scripts/deploy/systemd/tiku-worker-monthly-usage.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Collect and invoice previous-month SaaS usage monthly
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-01 04:00:00 Asia/Shanghai
|
||||
Persistent=true
|
||||
AccuracySec=1m
|
||||
RandomizedDelaySec=5m
|
||||
Unit=tiku-worker-monthly-usage.service
|
||||
|
||||
[Install]
|
||||
WantedBy=tiku-workers.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Convert high-risk platform audit events into alerts
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2m
|
||||
OnUnitInactiveSec=5m
|
||||
AccuracySec=30s
|
||||
RandomizedDelaySec=30s
|
||||
Unit=tiku-worker-job@platform-audit-alerts.service
|
||||
|
||||
[Install]
|
||||
WantedBy=tiku-workers.target
|
||||
11
scripts/deploy/systemd/tiku-worker-platform-billing.timer
Normal file
11
scripts/deploy/systemd/tiku-worker-platform-billing.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Generate upcoming SaaS subscription invoices daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 01:30:00 Asia/Shanghai
|
||||
Persistent=true
|
||||
RandomizedDelaySec=15m
|
||||
Unit=tiku-worker-job@platform-billing.service
|
||||
|
||||
[Install]
|
||||
WantedBy=tiku-workers.target
|
||||
11
scripts/deploy/systemd/tiku-worker-platform-dunning.timer
Normal file
11
scripts/deploy/systemd/tiku-worker-platform-dunning.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Process overdue SaaS invoices daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:00:00 Asia/Shanghai
|
||||
Persistent=true
|
||||
RandomizedDelaySec=15m
|
||||
Unit=tiku-worker-job@platform-dunning.service
|
||||
|
||||
[Install]
|
||||
WantedBy=tiku-workers.target
|
||||
11
scripts/deploy/systemd/tiku-worker-platform-usage.timer
Normal file
11
scripts/deploy/systemd/tiku-worker-platform-usage.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Collect current-month SaaS usage snapshots
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 02:00:00 Asia/Shanghai
|
||||
Persistent=true
|
||||
RandomizedDelaySec=15m
|
||||
Unit=tiku-worker-job@platform-usage.service
|
||||
|
||||
[Install]
|
||||
WantedBy=tiku-workers.target
|
||||
12
scripts/deploy/systemd/tiku-worker-student-supervision.timer
Normal file
12
scripts/deploy/systemd/tiku-worker-student-supervision.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Generate due student supervision follow-ups
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5m
|
||||
OnUnitInactiveSec=15m
|
||||
AccuracySec=1m
|
||||
RandomizedDelaySec=1m
|
||||
Unit=tiku-worker-job@student-supervision.service
|
||||
|
||||
[Install]
|
||||
WantedBy=tiku-workers.target
|
||||
@@ -1,7 +1,8 @@
|
||||
[Unit]
|
||||
Description=tiku-supabase worker
|
||||
After=network-online.target
|
||||
Description=tiku-supabase continuous worker (%i)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
PartOf=tiku-workers.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -9,7 +10,7 @@ User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/tiku-saas/repo
|
||||
EnvironmentFile=/etc/tiku-saas/worker.env
|
||||
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --loop
|
||||
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --loop --job %i
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillSignal=SIGTERM
|
||||
@@ -25,4 +26,4 @@ AmbientCapabilities=
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=tiku-workers.target
|
||||
20
scripts/deploy/systemd/tiku-workers.target
Normal file
20
scripts/deploy/systemd/tiku-workers.target
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=tiku-supabase production worker scheduler
|
||||
Requires=tiku-worker@crm.service
|
||||
Requires=tiku-worker@commerce.service
|
||||
Requires=tiku-worker@provider-bills.service
|
||||
Requires=tiku-worker@platform-dunning-notifications.service
|
||||
Requires=tiku-worker@platform-audit-notifications.service
|
||||
Requires=tiku-worker@assets.service
|
||||
Requires=tiku-worker@imports.service
|
||||
Requires=tiku-worker@public-banks.service
|
||||
Requires=tiku-worker@exports.service
|
||||
Wants=tiku-worker-platform-billing.timer
|
||||
Wants=tiku-worker-platform-usage.timer
|
||||
Wants=tiku-worker-platform-dunning.timer
|
||||
Wants=tiku-worker-platform-audit-alerts.timer
|
||||
Wants=tiku-worker-student-supervision.timer
|
||||
Wants=tiku-worker-monthly-usage.timer
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
203
scripts/destructive-test-database-guard-test.js
Normal file
203
scripts/destructive-test-database-guard-test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DESTRUCTIVE_TEST_CONFIRMATION,
|
||||
assertDestructiveTestDatabase,
|
||||
describeDatabaseTarget,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const LOCAL_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const CONFIRMATION = DESTRUCTIVE_TEST_CONFIRMATION;
|
||||
|
||||
function clientWithMarker(environment, allowDestructiveTests) {
|
||||
const queries = [];
|
||||
return {
|
||||
queries,
|
||||
client: {
|
||||
async query(sql) {
|
||||
queries.push(String(sql).trim().replace(/\s+/g, ' '));
|
||||
return { rows: [{ environment, allowDestructiveTests }], rowCount: 1 };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function rejects(options, pattern) {
|
||||
await assert.rejects(() => assertDestructiveTestDatabase(options), pattern);
|
||||
}
|
||||
|
||||
{
|
||||
const mock = clientWithMarker('local', true);
|
||||
const result = await assertDestructiveTestDatabase({
|
||||
client: mock.client,
|
||||
databaseUrl: LOCAL_URL,
|
||||
confirmation: CONFIRMATION,
|
||||
operation: 'smoke seed',
|
||||
});
|
||||
assert.equal(result.environment, 'local');
|
||||
assert.equal(mock.queries.length, 1);
|
||||
assert.match(mock.queries[0], /^select environment,/i);
|
||||
}
|
||||
|
||||
{
|
||||
const mock = clientWithMarker('ci', true);
|
||||
const result = await assertDestructiveTestDatabase({
|
||||
client: mock.client,
|
||||
databaseUrl: 'postgresql://ci_runner:secret@postgres-ci.internal:6432/tiku_ci',
|
||||
confirmation: CONFIRMATION,
|
||||
});
|
||||
assert.equal(result.environment, 'ci');
|
||||
}
|
||||
|
||||
for (const runtimeUser of ['tiku_api', 'tiku_worker']) {
|
||||
const mock = clientWithMarker('local', true);
|
||||
await rejects(
|
||||
{
|
||||
client: mock.client,
|
||||
databaseUrl: `postgresql://${runtimeUser}:prod-secret@127.0.0.1:5432/postgres`,
|
||||
confirmation: CONFIRMATION,
|
||||
},
|
||||
/reserved for production runtime/,
|
||||
);
|
||||
assert.equal(mock.queries.length, 0, 'known production targets must be rejected before SQL');
|
||||
}
|
||||
|
||||
{
|
||||
const missingMarkerClient = { query: async () => ({ rows: [], rowCount: 0 }) };
|
||||
await rejects(
|
||||
{
|
||||
client: missingMarkerClient,
|
||||
databaseUrl: 'postgresql://postgres:secret@127.0.0.1:15432/postgres',
|
||||
confirmation: CONFIRMATION,
|
||||
},
|
||||
/marker row is missing/,
|
||||
);
|
||||
}
|
||||
|
||||
for (const environment of ['production', 'staging']) {
|
||||
const mock = clientWithMarker(environment, true);
|
||||
await rejects(
|
||||
{ client: mock.client, databaseUrl: LOCAL_URL, confirmation: CONFIRMATION },
|
||||
new RegExp(`environment ${environment} is not approved`),
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const mock = clientWithMarker('local', false);
|
||||
await rejects(
|
||||
{ client: mock.client, databaseUrl: LOCAL_URL, confirmation: CONFIRMATION },
|
||||
/does not allow destructive tests/,
|
||||
);
|
||||
}
|
||||
|
||||
for (const confirmation of ['', 'wrong-confirmation']) {
|
||||
const mock = clientWithMarker('local', true);
|
||||
await rejects(
|
||||
{ client: mock.client, databaseUrl: LOCAL_URL, confirmation },
|
||||
/explicit confirmation/,
|
||||
);
|
||||
assert.equal(mock.queries.length, 0, 'confirmation must be checked before SQL');
|
||||
}
|
||||
|
||||
{
|
||||
const queryErrorClient = { query: async () => { throw new Error('password=do-not-print'); } };
|
||||
let error;
|
||||
try {
|
||||
await assertDestructiveTestDatabase({
|
||||
client: queryErrorClient,
|
||||
databaseUrl: LOCAL_URL,
|
||||
confirmation: CONFIRMATION,
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
assert.match(error?.message || '', /marker is unavailable or unreadable/);
|
||||
assert.equal(error.message.includes('do-not-print'), false);
|
||||
}
|
||||
|
||||
for (const malformed of ['', 'not-a-url', 'https://example.com/database', 'postgresql://localhost']) {
|
||||
await rejects(
|
||||
{ client: clientWithMarker('local', true).client, databaseUrl: malformed, confirmation: CONFIRMATION },
|
||||
/Refusing destructive database test/,
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const target = describeDatabaseTarget('postgresql://user:super-secret@db.example.test:5439/tiku_test');
|
||||
assert.deepEqual(target, {
|
||||
host: 'db.example.test',
|
||||
port: '5439',
|
||||
database: 'tiku_test',
|
||||
user: 'user',
|
||||
});
|
||||
assert.equal(JSON.stringify(target).includes('super-secret'), false);
|
||||
assert.equal(resolveDestructiveTestConfirmation({}, ['--confirm', CONFIRMATION]), CONFIRMATION);
|
||||
assert.equal(resolveDestructiveTestConfirmation({ SMOKE_SEED_CONFIRM: CONFIRMATION }, []), CONFIRMATION);
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const smokeSeed = fs.readFileSync(path.join(repoRoot, 'scripts', 'smoke-seed.js'), 'utf8');
|
||||
const apiIntegration = fs.readFileSync(path.join(repoRoot, 'scripts', 'api-integration-test.js'), 'utf8');
|
||||
const rlsTest = fs.readFileSync(path.join(repoRoot, 'scripts', 'rls-tenant-isolation-test.js'), 'utf8');
|
||||
const autoBadgeConcurrency = fs.readFileSync(
|
||||
path.join(repoRoot, 'scripts', 'auto-badge-concurrency-test.js'),
|
||||
'utf8',
|
||||
);
|
||||
const migration = fs.readFileSync(
|
||||
path.join(repoRoot, 'supabase', 'migrations', '202607120001_destructive_test_environment_safety.sql'),
|
||||
'utf8',
|
||||
);
|
||||
const seed = fs.readFileSync(path.join(repoRoot, 'supabase', 'seed.sql'), 'utf8');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
|
||||
for (const [name, source] of [
|
||||
['smoke seed', smokeSeed],
|
||||
['API integration test', apiIntegration],
|
||||
['RLS isolation test', rlsTest],
|
||||
['auto badge concurrency test', autoBadgeConcurrency],
|
||||
]) {
|
||||
assert.match(source, /assertDestructiveTestDatabase\s*\(/, `${name} must invoke the shared database guard`);
|
||||
}
|
||||
|
||||
const guardCall = smokeSeed.indexOf('assertDestructiveTestDatabase(');
|
||||
const firstBegin = smokeSeed.search(/client\.query\(['"]begin['"]\)/i);
|
||||
const firstWrite = smokeSeed.search(/\b(?:update|delete from|insert into)\s+public\./i);
|
||||
assert.ok(guardCall >= 0, 'smoke seed must call the shared guard');
|
||||
assert.ok(firstBegin > guardCall, 'smoke seed guard must run before BEGIN');
|
||||
assert.ok(firstWrite > guardCall, 'smoke seed guard must run before persistent SQL writes');
|
||||
|
||||
assert.match(migration, /create table if not exists app_private\.environment_safety/i);
|
||||
assert.match(migration, /environment in \('local', 'test', 'ci', 'staging', 'production'\)/i);
|
||||
assert.match(migration, /allow_destructive_tests boolean not null default false/i);
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/insert into app_private\.environment_safety/i,
|
||||
'migrations must not automatically authorize destructive tests',
|
||||
);
|
||||
assert.match(
|
||||
seed,
|
||||
/insert into app_private\.environment_safety[\s\S]*values \(true, 'local', true\)/i,
|
||||
'local Supabase seed must provision the local-only marker',
|
||||
);
|
||||
|
||||
assert.equal(packageJson.scripts?.['test:api:remote'], undefined, 'full API integration must not expose a remote mode');
|
||||
for (const scriptName of ['db:smoke-seed:test', 'test:api', 'test:rls']) {
|
||||
assert.match(
|
||||
packageJson.scripts?.[scriptName] || '',
|
||||
new RegExp(DESTRUCTIVE_TEST_CONFIRMATION),
|
||||
`${scriptName} must carry the exact destructive-test confirmation`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
packageJson.scripts?.['test:readiness'] || '',
|
||||
/auto-badge-concurrency-test\.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY/,
|
||||
'readiness must explicitly confirm its destructive concurrency test',
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.scripts?.['test:readiness']?.includes('destructive-test-database-guard-test.js'),
|
||||
'readiness contracts must include the destructive database guard test',
|
||||
);
|
||||
|
||||
console.log('[PASS] destructive test database fail-closed guard');
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './diagnose-aliyun-pnvs-provider.js';
|
||||
|
||||
const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||
const localDatabaseUrl = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
|
||||
function queryFixture({ providerRows = [], secretRows = [] }) {
|
||||
return async (sql, params) => {
|
||||
@@ -57,7 +58,7 @@ const goodSecretRows = [
|
||||
];
|
||||
|
||||
const ok = await diagnoseAliyunPnvsProvider(
|
||||
{ databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' },
|
||||
{ databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun-pnvs' },
|
||||
{ query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) },
|
||||
);
|
||||
|
||||
@@ -71,14 +72,14 @@ assert.ok(!JSON.stringify(ok).includes(goodSecretRows[0].accessKeyId), 'diagnost
|
||||
assert.match(ok.secret.accessKeyIdMasked, /^LTAI\.\.\./);
|
||||
|
||||
const missingSecret = await diagnoseAliyunPnvsProvider(
|
||||
{ databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' },
|
||||
{ databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun-pnvs' },
|
||||
{ query: queryFixture({ providerRows: goodProviderRows, secretRows: [] }) },
|
||||
);
|
||||
assert.equal(missingSecret.ok, false);
|
||||
assert.ok(missingSecret.checks.some(item => item.id === 'db.tenant_secret' && item.status === 'blocker'));
|
||||
|
||||
const wrongEnvProvider = await diagnoseAliyunPnvsProvider(
|
||||
{ databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun' },
|
||||
{ databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun' },
|
||||
{ query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) },
|
||||
);
|
||||
assert.equal(wrongEnvProvider.ok, false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './disable-legacy-sms-providers.js';
|
||||
|
||||
const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||
const localDatabaseUrl = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const rows = [
|
||||
{ id: 'legacy-aliyun', tenantId, provider: 'aliyun', status: 'active', displayName: '旧阿里云短信' },
|
||||
{ id: 'legacy-tencent', tenantId, provider: 'tencent-sms', status: 'testing', displayName: '旧腾讯云短信' },
|
||||
@@ -29,14 +30,14 @@ function queryFixture() {
|
||||
}
|
||||
|
||||
const cfg = buildConfig(
|
||||
{ DATABASE_URL: 'postgresql://example', PNVS_TENANT_ID: tenantId },
|
||||
{ DATABASE_URL: localDatabaseUrl, PNVS_TENANT_ID: tenantId },
|
||||
[],
|
||||
);
|
||||
assert.equal(cfg.apply, false);
|
||||
assert.equal(cfg.tenantId, tenantId);
|
||||
|
||||
const applyCfg = buildConfig(
|
||||
{ DATABASE_URL: 'postgresql://example', PNVS_TENANT_ID: tenantId },
|
||||
{ DATABASE_URL: localDatabaseUrl, PNVS_TENANT_ID: tenantId },
|
||||
['--apply'],
|
||||
);
|
||||
assert.equal(applyCfg.apply, true);
|
||||
@@ -45,7 +46,7 @@ const found = await findLegacySmsProviderRows(queryFixture(), tenantId);
|
||||
assert.deepEqual(found.map(row => row.id), ['legacy-aliyun', 'legacy-tencent']);
|
||||
|
||||
const dryRun = await disableLegacySmsProviders(
|
||||
{ databaseUrl: 'postgresql://example', tenantId, apply: false },
|
||||
{ databaseUrl: localDatabaseUrl, tenantId, apply: false },
|
||||
{ query: queryFixture() },
|
||||
);
|
||||
assert.equal(dryRun.dryRun, true);
|
||||
@@ -53,7 +54,7 @@ assert.equal(dryRun.changed, 0);
|
||||
assert.deepEqual(dryRun.rows.map(row => row.id), ['legacy-aliyun', 'legacy-tencent']);
|
||||
|
||||
const applyRun = await disableLegacySmsProviders(
|
||||
{ databaseUrl: 'postgresql://example', tenantId, apply: true },
|
||||
{ databaseUrl: localDatabaseUrl, tenantId, apply: true },
|
||||
{ query: queryFixture() },
|
||||
);
|
||||
assert.equal(applyRun.dryRun, false);
|
||||
|
||||
@@ -64,6 +64,12 @@ def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def write_json_file(file_path: pathlib.Path, payload: Any) -> None:
|
||||
with file_path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Export PocketBase SQLite collections to JSON.")
|
||||
parser.add_argument("--data-db", required=True, help="Path to PocketBase data.db")
|
||||
@@ -371,19 +377,11 @@ def main() -> int:
|
||||
"generatedAt": utc_now_iso(),
|
||||
"collections": collections,
|
||||
}
|
||||
(export_dir / "pb_schema.sqlite.json").write_text(
|
||||
json.dumps(schema_payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
write_json_file(export_dir / "pb_schema.sqlite.json", schema_payload)
|
||||
|
||||
storage_manifest = build_storage_manifest(storage_dir, collections) if storage_dir else None
|
||||
if storage_manifest:
|
||||
(export_dir / "storage-manifest.json").write_text(
|
||||
json.dumps(storage_manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
write_json_file(export_dir / "storage-manifest.json", storage_manifest)
|
||||
|
||||
aux = auxiliary_summary(aux_db, args.include_aux_logs) if aux_db else None
|
||||
|
||||
@@ -416,11 +414,7 @@ def main() -> int:
|
||||
"auxiliary": aux,
|
||||
}
|
||||
|
||||
(export_dir / "sqlite-export-manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
write_json_file(export_dir / "sqlite-export-manifest.json", manifest)
|
||||
|
||||
print(json.dumps(manifest, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
@@ -1,64 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import pg from 'pg';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import {
|
||||
assertDestructiveTestDatabase,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||
const adminUserId = '00000000-0000-0000-0000-000000000102';
|
||||
const confirmation = resolveDestructiveTestConfirmation();
|
||||
const ids = {
|
||||
region: '00000000-0000-0000-0000-000000000301',
|
||||
subject: '00000000-0000-0000-0000-000000000501',
|
||||
category: '00000000-0000-0000-0000-000000000601',
|
||||
contentEntry: '00000000-0000-0000-0000-000000000611',
|
||||
contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614',
|
||||
questionCollection: '00000000-0000-0000-0000-000000000615',
|
||||
};
|
||||
|
||||
function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'imports'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_IMPORT_BATCH_SIZE: '5',
|
||||
WORKER_IMPORT_ID: 'imports-integration-test',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('exit', code => {
|
||||
try {
|
||||
assert.equal(code, 0, `worker should exit 0\n${output}`);
|
||||
assert.match(output, /imports batch processed=\d+/, 'worker output should include imports summary');
|
||||
resolve(output);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
process.env.DATABASE_URL = databaseUrl;
|
||||
process.env.WORKER_IMPORT_BATCH_SIZE = '5';
|
||||
process.env.WORKER_IMPORT_ID = 'imports-integration-default';
|
||||
process.env.WORKER_IMPORT_LEASE_SECONDS = '120';
|
||||
process.env.WORKER_IMPORT_HEARTBEAT_INTERVAL_MS = '30000';
|
||||
|
||||
const workerModuleUrl = pathToFileURL(fileURLToPath(new URL('../apps/worker/dist/apps/worker/src/jobs/imports.js', import.meta.url))).href;
|
||||
const apiModuleUrl = pathToFileURL(fileURLToPath(new URL('../apps/worker/dist/apps/api/src/features/tenant-content/imports.js', import.meta.url))).href;
|
||||
const worker = await import(workerModuleUrl);
|
||||
const { executeContentImportJob } = await import(apiModuleUrl);
|
||||
|
||||
function auth() {
|
||||
return {
|
||||
tenantId,
|
||||
userId: adminUserId,
|
||||
role: 'system_worker',
|
||||
permissions: { 'content:*': true },
|
||||
templatePermissions: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'pending',
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
next_attempt_at = now(),
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and source_name like 'worker-lease-%'
|
||||
and status = 'importing'
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.question_collection_items
|
||||
where tenant_id = $1
|
||||
and question_id in (
|
||||
select id from public.questions
|
||||
where tenant_id = $1
|
||||
and (
|
||||
legacy_id like 'worker-import-question-%'
|
||||
or legacy_id like 'integration-import-async-choice-%'
|
||||
)
|
||||
where tenant_id = $1 and legacy_id like 'worker-lease-question-%'
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
@@ -69,24 +75,13 @@ async function cleanup(pool) {
|
||||
where tenant_id = $1
|
||||
and question_id in (
|
||||
select id from public.questions
|
||||
where tenant_id = $1
|
||||
and (
|
||||
legacy_id like 'worker-import-question-%'
|
||||
or legacy_id like 'integration-import-async-choice-%'
|
||||
)
|
||||
where tenant_id = $1 and legacy_id like 'worker-lease-question-%'
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.questions
|
||||
where tenant_id = $1
|
||||
and (
|
||||
legacy_id like 'worker-import-question-%'
|
||||
or legacy_id like 'integration-import-async-choice-%'
|
||||
)
|
||||
`,
|
||||
`delete from public.questions where tenant_id = $1 and legacy_id like 'worker-lease-question-%'`,
|
||||
[tenantId],
|
||||
);
|
||||
await pool.query(
|
||||
@@ -94,24 +89,21 @@ async function cleanup(pool) {
|
||||
delete from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and target_type = 'content_import_job'
|
||||
and details::text like '%worker-import%'
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_import_jobs
|
||||
where tenant_id = $1
|
||||
and (
|
||||
source_name like 'worker-import-%'
|
||||
or source_name = 'async-question-import.json'
|
||||
and target_id in (
|
||||
select id::text from public.content_import_jobs
|
||||
where tenant_id = $1 and source_name like 'worker-lease-%'
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
await pool.query(
|
||||
`delete from public.content_import_jobs where tenant_id = $1 and source_name like 'worker-lease-%'`,
|
||||
[tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
async function createQueuedQuestionImport(pool) {
|
||||
async function createQueuedQuestionImport(pool, suffix, maxAttempts = 3) {
|
||||
const legacyId = `worker-lease-question-${suffix}`;
|
||||
const preview = await pool.query(
|
||||
`
|
||||
insert into public.content_import_jobs (
|
||||
@@ -120,20 +112,21 @@ async function createQueuedQuestionImport(pool) {
|
||||
target_category_id, target_content_node_id, target_collection_id,
|
||||
dry_run, total_count, valid_count, error_count, warning_count,
|
||||
summary, raw_payload, normalized_payload, execution_mode, queued_at,
|
||||
next_attempt_at, parser_metadata
|
||||
next_attempt_at, max_attempts, parser_metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'questions', 'json', 'pending',
|
||||
'worker-import-questions.json', 'worker-import-source-hash',
|
||||
$3::uuid, $4::uuid, $5::uuid, $6::uuid, $7::uuid,
|
||||
$3, $4, $5::uuid, $6::uuid, $7::uuid, $8::uuid, $9::uuid,
|
||||
false, 1, 1, 0, 0,
|
||||
$8::jsonb, $9::jsonb, $10::jsonb, 'async', now(), now(), '{}'::jsonb
|
||||
$10::jsonb, $11::jsonb, $12::jsonb, 'async', now(), now(), $13, '{}'::jsonb
|
||||
)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
adminUserId,
|
||||
`worker-lease-${suffix}.json`,
|
||||
`worker-lease-source-${suffix}`,
|
||||
ids.region,
|
||||
ids.subject,
|
||||
ids.category,
|
||||
@@ -148,41 +141,29 @@ async function createQueuedQuestionImport(pool) {
|
||||
collectionId: ids.questionCollection,
|
||||
},
|
||||
importOptions: { allowPartial: false },
|
||||
source: 'worker-import-integration',
|
||||
source: 'worker-import-lease-integration',
|
||||
}),
|
||||
JSON.stringify([
|
||||
{
|
||||
legacyId: 'worker-import-question-001',
|
||||
type: 'choice',
|
||||
content: '异步导入题:worker 应该复用哪套导入规则?',
|
||||
options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'],
|
||||
correctOptionIndices: [1],
|
||||
explanation: 'worker 和 API 必须复用同一套后端导入规则。',
|
||||
difficulty: 2,
|
||||
tags: ['worker-import'],
|
||||
},
|
||||
]),
|
||||
JSON.stringify([
|
||||
{
|
||||
legacyId: 'worker-import-question-001',
|
||||
type: 'choice',
|
||||
typeLabel: null,
|
||||
content: '异步导入题:worker 应该复用哪套导入规则?',
|
||||
options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'],
|
||||
correctOptionIndex: 1,
|
||||
correctOptionIndices: [1],
|
||||
answerText: null,
|
||||
explanation: 'worker 和 API 必须复用同一套后端导入规则。',
|
||||
difficulty: 2,
|
||||
tags: ['worker-import'],
|
||||
mediaUrl: null,
|
||||
subQuestions: [],
|
||||
codeLang: null,
|
||||
codeTemplate: null,
|
||||
examMarkers: {},
|
||||
sourceHash: 'worker-import-question-hash-001',
|
||||
},
|
||||
]),
|
||||
JSON.stringify([{ legacyId, type: 'choice', content: `lease test ${suffix}` }]),
|
||||
JSON.stringify([{
|
||||
legacyId,
|
||||
type: 'choice',
|
||||
typeLabel: null,
|
||||
content: `lease test ${suffix}`,
|
||||
options: ['A', 'B'],
|
||||
correctOptionIndex: 1,
|
||||
correctOptionIndices: [1],
|
||||
answerText: null,
|
||||
explanation: 'persistent lease integration fixture',
|
||||
difficulty: 2,
|
||||
tags: ['worker-lease'],
|
||||
mediaUrl: null,
|
||||
subQuestions: [],
|
||||
codeLang: null,
|
||||
codeTemplate: null,
|
||||
examMarkers: {},
|
||||
sourceHash: `worker-lease-hash-${suffix}`,
|
||||
}]),
|
||||
maxAttempts,
|
||||
],
|
||||
);
|
||||
const jobId = preview.rows[0].id;
|
||||
@@ -192,108 +173,236 @@ async function createQueuedQuestionImport(pool) {
|
||||
tenant_id, job_id, row_no, external_id, status, target_type,
|
||||
source_payload, normalized_payload, content_hash, issues_count
|
||||
)
|
||||
values ($1, $2, 1, 'worker-import-question-001', 'valid', 'question', $3::jsonb, $4::jsonb, 'worker-import-question-hash-001', 0)
|
||||
values ($1, $2, 1, $3, 'valid', 'question', $4::jsonb, $5::jsonb, $6, 0)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
jobId,
|
||||
legacyId,
|
||||
JSON.stringify({ legacyId, content: `lease test ${suffix}` }),
|
||||
JSON.stringify({
|
||||
legacyId: 'worker-import-question-001',
|
||||
content: '异步导入题:worker 应该复用哪套导入规则?',
|
||||
}),
|
||||
JSON.stringify({
|
||||
legacyId: 'worker-import-question-001',
|
||||
legacyId,
|
||||
type: 'choice',
|
||||
typeLabel: null,
|
||||
content: '异步导入题:worker 应该复用哪套导入规则?',
|
||||
options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'],
|
||||
content: `lease test ${suffix}`,
|
||||
options: ['A', 'B'],
|
||||
correctOptionIndex: 1,
|
||||
correctOptionIndices: [1],
|
||||
answerText: null,
|
||||
explanation: 'worker 和 API 必须复用同一套后端导入规则。',
|
||||
explanation: 'persistent lease integration fixture',
|
||||
difficulty: 2,
|
||||
tags: ['worker-import'],
|
||||
tags: ['worker-lease'],
|
||||
mediaUrl: null,
|
||||
subQuestions: [],
|
||||
codeLang: null,
|
||||
codeTemplate: null,
|
||||
examMarkers: {},
|
||||
sourceHash: 'worker-import-question-hash-001',
|
||||
sourceHash: `worker-lease-hash-${suffix}`,
|
||||
}),
|
||||
`worker-lease-hash-${suffix}`,
|
||||
],
|
||||
);
|
||||
return jobId;
|
||||
return { jobId, legacyId };
|
||||
}
|
||||
|
||||
async function readJob(pool, jobId) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
select status, attempt_count as "attemptCount", locked_by as "lockedBy",
|
||||
lease_token as "leaseToken", lease_expires_at as "leaseExpiresAt",
|
||||
last_heartbeat_at as "lastHeartbeatAt", next_attempt_at as "nextAttemptAt",
|
||||
inserted_count as "insertedCount", error_message as "errorMessage"
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, jobId],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function testAtomicClaim(pool) {
|
||||
const jobs = await Promise.all([
|
||||
createQueuedQuestionImport(pool, 'atomic-a'),
|
||||
createQueuedQuestionImport(pool, 'atomic-b'),
|
||||
createQueuedQuestionImport(pool, 'atomic-c'),
|
||||
]);
|
||||
const [left, right] = await Promise.all([
|
||||
worker.claimImportJobs({ workerId: 'lease-worker-a', batchSize: 2, leaseSeconds: 30 }),
|
||||
worker.claimImportJobs({ workerId: 'lease-worker-b', batchSize: 2, leaseSeconds: 30 }),
|
||||
]);
|
||||
const claimed = [...left, ...right];
|
||||
assert.equal(claimed.length, 3, 'concurrent workers should claim all three jobs');
|
||||
assert.equal(new Set(claimed.map(job => job.id)).size, 3, 'SKIP LOCKED claim must not duplicate a job');
|
||||
assert.deepEqual(
|
||||
new Set(claimed.map(job => job.id)),
|
||||
new Set(jobs.map(job => job.jobId)),
|
||||
'claims must stay within the ready fixture set',
|
||||
);
|
||||
for (const job of claimed) {
|
||||
assert.equal(job.attemptCount, 1, 'claim should atomically increment attempt_count once');
|
||||
assert.ok(job.leaseToken, 'claim should persist a fencing token');
|
||||
}
|
||||
}
|
||||
|
||||
async function testHeartbeat(pool) {
|
||||
const fixture = await createQueuedQuestionImport(pool, 'heartbeat');
|
||||
const [claimed] = await worker.claimImportJobs({
|
||||
workerId: 'lease-heartbeat-worker',
|
||||
batchSize: 1,
|
||||
leaseSeconds: 3,
|
||||
});
|
||||
assert.equal(claimed.id, fixture.jobId);
|
||||
const before = await readJob(pool, fixture.jobId);
|
||||
const heartbeat = worker.startImportLeaseHeartbeat(claimed, {
|
||||
leaseSeconds: 3,
|
||||
heartbeatIntervalMs: 250,
|
||||
});
|
||||
await delay(700);
|
||||
await heartbeat.stop();
|
||||
const after = await readJob(pool, fixture.jobId);
|
||||
assert.ok(after.lastHeartbeatAt > before.lastHeartbeatAt, 'heartbeat should advance last_heartbeat_at');
|
||||
assert.ok(after.leaseExpiresAt > before.leaseExpiresAt, 'heartbeat should extend lease expiry');
|
||||
}
|
||||
|
||||
async function testExpiredTakeoverAndFencing(pool) {
|
||||
const fixture = await createQueuedQuestionImport(pool, 'takeover');
|
||||
const [first] = await worker.claimImportJobs({ workerId: 'lease-old-worker', batchSize: 1, leaseSeconds: 30 });
|
||||
await pool.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set locked_at = now() - interval '10 seconds',
|
||||
lease_expires_at = now() - interval '1 second',
|
||||
last_heartbeat_at = now() - interval '10 seconds'
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, fixture.jobId],
|
||||
);
|
||||
|
||||
const [second] = await worker.claimImportJobs({ workerId: 'lease-new-worker', batchSize: 1, leaseSeconds: 30 });
|
||||
assert.equal(second.id, fixture.jobId, 'expired importing job should be reclaimed');
|
||||
assert.notEqual(second.leaseToken, first.leaseToken, 'takeover must rotate fencing token');
|
||||
assert.equal(second.attemptCount, 2, 'takeover should consume exactly one additional attempt');
|
||||
|
||||
await assert.rejects(
|
||||
executeContentImportJob(auth(), {
|
||||
jobId: fixture.jobId,
|
||||
importType: 'questions',
|
||||
allowPartial: false,
|
||||
allowQueuedJob: true,
|
||||
leaseToken: first.leaseToken,
|
||||
}),
|
||||
error => error?.code === 'IMPORT_WORKER_LEASE_LOST',
|
||||
'old worker must be fenced before it can write imported content',
|
||||
);
|
||||
assert.equal(
|
||||
(await pool.query(`select count(*)::int as count from public.questions where tenant_id = $1 and legacy_id = $2`, [tenantId, fixture.legacyId])).rows[0].count,
|
||||
0,
|
||||
'fenced old worker must leave no business writes',
|
||||
);
|
||||
|
||||
const staleFailure = await worker.markImportFailed(first, new Error('stale worker failure'));
|
||||
assert.equal(staleFailure, 'lease_lost', 'old worker must not schedule retry or failure after takeover');
|
||||
const afterStaleFailure = await readJob(pool, fixture.jobId);
|
||||
assert.equal(afterStaleFailure.leaseToken, second.leaseToken, 'stale failure must not overwrite current lease');
|
||||
|
||||
const execution = await executeContentImportJob(auth(), {
|
||||
jobId: fixture.jobId,
|
||||
importType: 'questions',
|
||||
allowPartial: false,
|
||||
allowQueuedJob: true,
|
||||
leaseToken: second.leaseToken,
|
||||
});
|
||||
assert.equal(execution.status, 'completed', 'current lease owner should complete import');
|
||||
const completed = await readJob(pool, fixture.jobId);
|
||||
assert.equal(completed.status, 'completed');
|
||||
assert.equal(completed.attemptCount, 2, 'successful takeover must preserve exact attempt count');
|
||||
assert.equal(completed.leaseToken, null, 'terminal transition should release fencing token');
|
||||
assert.equal(Number(completed.insertedCount), 1);
|
||||
|
||||
await assert.rejects(
|
||||
executeContentImportJob(auth(), {
|
||||
jobId: fixture.jobId,
|
||||
importType: 'questions',
|
||||
allowPartial: false,
|
||||
allowQueuedJob: true,
|
||||
leaseToken: first.leaseToken,
|
||||
}),
|
||||
error => error?.code === 'IMPORT_WORKER_LEASE_LOST',
|
||||
'old worker must not turn a completed takeover into an idempotent success',
|
||||
);
|
||||
}
|
||||
|
||||
async function testRetryState(pool) {
|
||||
const fixture = await createQueuedQuestionImport(pool, 'retry');
|
||||
const [first] = await worker.claimImportJobs({ workerId: 'lease-retry-worker', batchSize: 1, leaseSeconds: 30 });
|
||||
const state = await worker.markImportFailed(first, Object.assign(new Error('retry fixture'), { code: 'RETRY_FIXTURE' }));
|
||||
assert.equal(state, 'retrying');
|
||||
const pending = await readJob(pool, fixture.jobId);
|
||||
assert.equal(pending.status, 'pending');
|
||||
assert.equal(pending.attemptCount, 1, 'retry scheduling must not increment attempt count');
|
||||
assert.equal(pending.leaseToken, null, 'retry scheduling must release lease');
|
||||
assert.ok(pending.nextAttemptAt, 'retry scheduling should persist next_attempt_at');
|
||||
|
||||
await pool.query(
|
||||
`update public.content_import_jobs set next_attempt_at = now() - interval '1 second' where tenant_id = $1 and id = $2`,
|
||||
[tenantId, fixture.jobId],
|
||||
);
|
||||
const [second] = await worker.claimImportJobs({ workerId: 'lease-retry-worker-2', batchSize: 1, leaseSeconds: 30 });
|
||||
assert.equal(second.id, fixture.jobId);
|
||||
assert.equal(second.attemptCount, 2, 'retry claim should increment attempt exactly once');
|
||||
assert.notEqual(second.leaseToken, first.leaseToken, 'each attempt must receive a new fencing token');
|
||||
}
|
||||
|
||||
async function testExpiredFinalAttempt(pool) {
|
||||
const fixture = await createQueuedQuestionImport(pool, 'exhausted', 1);
|
||||
await worker.claimImportJobs({ workerId: 'lease-crashed-final-worker', batchSize: 1, leaseSeconds: 30 });
|
||||
await pool.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set locked_at = now() - interval '10 seconds',
|
||||
lease_expires_at = now() - interval '1 second',
|
||||
last_heartbeat_at = now() - interval '10 seconds'
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, fixture.jobId],
|
||||
);
|
||||
const claimed = await worker.claimImportJobs({ workerId: 'lease-reaper-worker', batchSize: 1, leaseSeconds: 30 });
|
||||
assert.equal(claimed.length, 0, 'expired final attempt must not be executed again');
|
||||
const failed = await readJob(pool, fixture.jobId);
|
||||
assert.equal(failed.status, 'failed', 'expired final attempt should become terminal failed');
|
||||
assert.equal(failed.attemptCount, 1, 'reaping final attempt must not inflate attempts');
|
||||
assert.equal(failed.leaseToken, null, 'terminal reaper should release lease');
|
||||
assert.match(failed.errorMessage, /lease expired/i);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 12 });
|
||||
try {
|
||||
await assertDestructiveTestDatabase({
|
||||
client: pool,
|
||||
databaseUrl,
|
||||
confirmation,
|
||||
operation: 'import worker lease integration test',
|
||||
});
|
||||
await cleanup(pool);
|
||||
const jobId = await createQueuedQuestionImport(pool);
|
||||
|
||||
const output = await runWorkerOnce();
|
||||
assert.match(output, /completed=1/, 'worker should complete exactly the queued import job after cleanup');
|
||||
|
||||
const job = await pool.query(
|
||||
`
|
||||
select status, execution_mode, inserted_count, updated_count, skipped_count,
|
||||
locked_at, locked_by, attempt_count, error_message
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[tenantId, jobId],
|
||||
);
|
||||
assert.equal(job.rows[0]?.status, 'completed', 'queued import job should be completed');
|
||||
assert.equal(job.rows[0]?.execution_mode, 'async', 'job should keep async execution mode');
|
||||
assert.equal(Number(job.rows[0]?.inserted_count), 1, 'worker should insert one question');
|
||||
assert.equal(job.rows[0]?.locked_at, null, 'completed job should release lock');
|
||||
assert.equal(job.rows[0]?.locked_by, null, 'completed job should clear lock owner');
|
||||
assert.equal(Number(job.rows[0]?.attempt_count), 1, 'worker should record one attempt');
|
||||
assert.equal(job.rows[0]?.error_message, null, 'completed job should not retain error message');
|
||||
|
||||
const question = await pool.query(
|
||||
`
|
||||
select q.id, v.content
|
||||
from public.questions q
|
||||
join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.legacy_id = 'worker-import-question-001'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
assert.equal(question.rows[0]?.content, '异步导入题:worker 应该复用哪套导入规则?', 'worker should import question content');
|
||||
|
||||
const collectionItem = await pool.query(
|
||||
`
|
||||
select 1
|
||||
from public.question_collection_items
|
||||
where tenant_id = $1 and collection_id = $2 and question_id = $3
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, ids.questionCollection, question.rows[0]?.id],
|
||||
);
|
||||
assert.equal(collectionItem.rowCount, 1, 'worker should bind imported question to collection');
|
||||
|
||||
const audit = await pool.query(
|
||||
`
|
||||
select action
|
||||
from public.audit_logs
|
||||
where tenant_id = $1 and target_type = 'content_import_job' and target_id = $2
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, jobId],
|
||||
);
|
||||
assert.equal(audit.rows[0]?.action, 'content.import.questions.completed', 'worker import should write completion audit');
|
||||
|
||||
console.log('Import worker integration test complete.');
|
||||
await testAtomicClaim(pool);
|
||||
await cleanup(pool);
|
||||
await testHeartbeat(pool);
|
||||
await cleanup(pool);
|
||||
await testExpiredTakeoverAndFencing(pool);
|
||||
await cleanup(pool);
|
||||
await testRetryState(pool);
|
||||
await cleanup(pool);
|
||||
await testExpiredFinalAttempt(pool);
|
||||
console.log('Import worker lease integration test complete.');
|
||||
} finally {
|
||||
await cleanup(pool).catch(() => {});
|
||||
await cleanup(pool).catch(() => undefined);
|
||||
await worker.closeImportExecutorPool().catch(() => undefined);
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
148
scripts/lib/destructive-test-database-guard.js
Normal file
148
scripts/lib/destructive-test-database-guard.js
Normal file
@@ -0,0 +1,148 @@
|
||||
export const DESTRUCTIVE_TEST_CONFIRMATION = 'SMOKE_SEED_LOCAL_OR_CI_ONLY';
|
||||
|
||||
const ALLOWED_ENVIRONMENTS = new Set(['local', 'test', 'ci']);
|
||||
const KNOWN_PRODUCTION_DATABASE_USERS = new Set(['tiku_api', 'tiku_worker']);
|
||||
|
||||
function argumentValue(argv, name) {
|
||||
const directIndex = argv.indexOf(name);
|
||||
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
|
||||
const prefix = `${name}=`;
|
||||
return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim();
|
||||
}
|
||||
|
||||
export function resolveDestructiveTestConfirmation(
|
||||
env = process.env,
|
||||
argv = process.argv.slice(2),
|
||||
) {
|
||||
return argumentValue(argv, '--confirm') || String(env.SMOKE_SEED_CONFIRM || '').trim();
|
||||
}
|
||||
|
||||
export function describeDatabaseTarget(databaseUrl) {
|
||||
if (!databaseUrl || typeof databaseUrl !== 'string') {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(databaseUrl);
|
||||
} catch {
|
||||
throw new Error('DATABASE_URL must be a valid PostgreSQL URL');
|
||||
}
|
||||
if (!['postgres:', 'postgresql:'].includes(parsed.protocol)) {
|
||||
throw new Error('DATABASE_URL must use the postgres or postgresql protocol');
|
||||
}
|
||||
if (!parsed.hostname || !parsed.pathname || parsed.pathname === '/') {
|
||||
throw new Error('DATABASE_URL must include a host and database name');
|
||||
}
|
||||
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port || '5432',
|
||||
database: decodeURIComponent(parsed.pathname.slice(1)),
|
||||
user: decodeURIComponent(parsed.username || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function targetText(target, environment = 'unavailable') {
|
||||
const safe = value => String(value || '[missing]')
|
||||
.replace(/[\u0000-\u001f\u007f\s]+/g, '_')
|
||||
.slice(0, 160);
|
||||
return [
|
||||
`host=${safe(target.host)}`,
|
||||
`port=${safe(target.port)}`,
|
||||
`database=${safe(target.database)}`,
|
||||
`user=${safe(target.user)}`,
|
||||
`databaseEnvironment=${safe(environment)}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function refusal(operation, reason, target, environment) {
|
||||
return new Error(
|
||||
`Refusing ${operation}: ${reason}. Target: ${targetText(target, environment)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function knownProductionReason(target) {
|
||||
const user = target.user.toLowerCase();
|
||||
const host = target.host.toLowerCase();
|
||||
if (KNOWN_PRODUCTION_DATABASE_USERS.has(user)) {
|
||||
return 'database user is reserved for production runtime';
|
||||
}
|
||||
if (host === 'tjszsb.com' || host.endsWith('.tjszsb.com')) {
|
||||
return 'database host belongs to the production domain';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function assertDestructiveTestDatabase({
|
||||
client,
|
||||
databaseUrl,
|
||||
confirmation = resolveDestructiveTestConfirmation(),
|
||||
operation = 'destructive database test',
|
||||
} = {}) {
|
||||
let target;
|
||||
try {
|
||||
target = describeDatabaseTarget(databaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Refusing ${operation}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (confirmation !== DESTRUCTIVE_TEST_CONFIRMATION) {
|
||||
throw refusal(
|
||||
operation,
|
||||
`explicit confirmation ${DESTRUCTIVE_TEST_CONFIRMATION} is required`,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
const productionReason = knownProductionReason(target);
|
||||
if (productionReason) {
|
||||
throw refusal(operation, productionReason, target);
|
||||
}
|
||||
if (!client || typeof client.query !== 'function') {
|
||||
throw refusal(operation, 'a connected PostgreSQL client is required', target);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await client.query(
|
||||
`
|
||||
select environment,
|
||||
allow_destructive_tests as "allowDestructiveTests"
|
||||
from app_private.environment_safety
|
||||
where id = true
|
||||
limit 1
|
||||
`,
|
||||
);
|
||||
} catch {
|
||||
throw refusal(
|
||||
operation,
|
||||
'database safety marker is unavailable or unreadable',
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
const marker = result?.rows?.[0];
|
||||
const environment = String(marker?.environment || 'missing').toLowerCase();
|
||||
if (!marker) {
|
||||
throw refusal(operation, 'database safety marker row is missing', target, environment);
|
||||
}
|
||||
if (!ALLOWED_ENVIRONMENTS.has(environment)) {
|
||||
throw refusal(
|
||||
operation,
|
||||
`database environment ${environment} is not approved for destructive tests`,
|
||||
target,
|
||||
environment,
|
||||
);
|
||||
}
|
||||
if (marker.allowDestructiveTests !== true) {
|
||||
throw refusal(
|
||||
operation,
|
||||
'database marker does not allow destructive tests',
|
||||
target,
|
||||
environment,
|
||||
);
|
||||
}
|
||||
|
||||
return { target, environment, allowDestructiveTests: true };
|
||||
}
|
||||
249
scripts/lib/tenant-foreign-key-audit.js
Normal file
249
scripts/lib/tenant-foreign-key-audit.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const TENANT_FOREIGN_KEY_AUDIT_KIND = 'tenant-foreign-key-audit';
|
||||
export const EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT = 189;
|
||||
export const EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256 = '884a5a59c101299551c27bde83f82b9738074a8729da9284775f75537f615868';
|
||||
|
||||
const TENANT_FOREIGN_KEY_EXCEPTIONS = new Map([
|
||||
[
|
||||
'platform_audit_alerts.platform_audit_alerts_rule_id_fkey',
|
||||
{
|
||||
mode: 'global-or-same-tenant-parent',
|
||||
reason: 'Platform audit rules may be global (tenant_id is null) or scoped to the alert tenant.',
|
||||
childColumn: 'rule_id',
|
||||
parentTable: 'platform_audit_alert_rules',
|
||||
parentColumn: 'id',
|
||||
},
|
||||
],
|
||||
[
|
||||
'tenant_question_bank_adoptions.tenant_question_bank_adoptions_source_question_bank_id_fkey',
|
||||
{
|
||||
mode: 'platform-source-or-same-tenant-parent',
|
||||
reason: 'A tenant adoption may reference a platform-owned public question bank.',
|
||||
childColumn: 'source_question_bank_id',
|
||||
parentTable: 'question_banks',
|
||||
parentColumn: 'id',
|
||||
},
|
||||
],
|
||||
[
|
||||
'tenant_content_notifications.tenant_content_notifications_source_question_bank_id_fkey',
|
||||
{
|
||||
mode: 'platform-source-or-same-tenant-parent',
|
||||
reason: 'A tenant notification may identify the platform-owned public question bank that triggered it.',
|
||||
childColumn: 'source_question_bank_id',
|
||||
parentTable: 'question_banks',
|
||||
parentColumn: 'id',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const RELATION_QUERY = `
|
||||
with tenant_tables as (
|
||||
select cls.oid, cls.relname
|
||||
from pg_class cls
|
||||
join pg_namespace ns on ns.oid = cls.relnamespace
|
||||
where ns.nspname = 'public'
|
||||
and cls.relkind in ('r', 'p')
|
||||
and exists (
|
||||
select 1
|
||||
from pg_attribute attribute
|
||||
where attribute.attrelid = cls.oid
|
||||
and attribute.attname = 'tenant_id'
|
||||
and not attribute.attisdropped
|
||||
)
|
||||
)
|
||||
select child.relname as "childTable",
|
||||
constraint_row.conname as "constraintName",
|
||||
parent.relname as "parentTable",
|
||||
array(
|
||||
select attribute.attname
|
||||
from unnest(constraint_row.conkey) with ordinality key_column(attnum, ordinal)
|
||||
join pg_attribute attribute
|
||||
on attribute.attrelid = constraint_row.conrelid
|
||||
and attribute.attnum = key_column.attnum
|
||||
order by key_column.ordinal
|
||||
) as "childColumns",
|
||||
array(
|
||||
select attribute.attname
|
||||
from unnest(constraint_row.confkey) with ordinality key_column(attnum, ordinal)
|
||||
join pg_attribute attribute
|
||||
on attribute.attrelid = constraint_row.confrelid
|
||||
and attribute.attnum = key_column.attnum
|
||||
order by key_column.ordinal
|
||||
) as "parentColumns",
|
||||
constraint_row.convalidated as validated,
|
||||
constraint_row.confupdtype as "updateAction",
|
||||
constraint_row.confdeltype as "deleteAction"
|
||||
from pg_constraint constraint_row
|
||||
join tenant_tables child on child.oid = constraint_row.conrelid
|
||||
join tenant_tables parent on parent.oid = constraint_row.confrelid
|
||||
where constraint_row.contype = 'f'
|
||||
and not exists (
|
||||
select 1
|
||||
from unnest(constraint_row.conkey) key_column(attnum)
|
||||
join pg_attribute attribute
|
||||
on attribute.attrelid = constraint_row.conrelid
|
||||
and attribute.attnum = key_column.attnum
|
||||
where attribute.attname = 'tenant_id'
|
||||
)
|
||||
order by child.relname, constraint_row.conname
|
||||
`;
|
||||
|
||||
function textArray(value) {
|
||||
if (Array.isArray(value)) return value.map(item => String(item));
|
||||
if (typeof value !== 'string' || value.length < 2) return [];
|
||||
return value.slice(1, -1).split(',').filter(Boolean).map(item => item.replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
export function normalizeTenantForeignKeyRelation(row) {
|
||||
return {
|
||||
childTable: String(row.childTable || row.child_table || ''),
|
||||
constraintName: String(row.constraintName || row.constraint_name || ''),
|
||||
childColumns: textArray(row.childColumns || row.child_columns),
|
||||
parentTable: String(row.parentTable || row.parent_table || ''),
|
||||
parentColumns: textArray(row.parentColumns || row.parent_columns),
|
||||
validated: row.validated === true,
|
||||
updateAction: String(row.updateAction || row.update_action || ''),
|
||||
deleteAction: String(row.deleteAction || row.delete_action || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function tenantForeignKeyRelationKey(relation) {
|
||||
return `${relation.childTable}.${relation.constraintName}`;
|
||||
}
|
||||
|
||||
export function tenantForeignKeyRelationCanonical(relation) {
|
||||
return [
|
||||
relation.childTable,
|
||||
relation.constraintName,
|
||||
relation.childColumns.join(','),
|
||||
relation.parentTable,
|
||||
relation.parentColumns.join(','),
|
||||
relation.validated ? 'validated' : 'not-valid',
|
||||
`update:${relation.updateAction}`,
|
||||
`delete:${relation.deleteAction}`,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function tenantForeignKeySchemaSha256(relations) {
|
||||
const canonical = relations
|
||||
.map(normalizeTenantForeignKeyRelation)
|
||||
.sort((left, right) => tenantForeignKeyRelationKey(left).localeCompare(tenantForeignKeyRelationKey(right)))
|
||||
.map(tenantForeignKeyRelationCanonical)
|
||||
.join('\n');
|
||||
return crypto.createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
function exceptionFor(relation) {
|
||||
const key = tenantForeignKeyRelationKey(relation);
|
||||
const exception = TENANT_FOREIGN_KEY_EXCEPTIONS.get(key);
|
||||
if (!exception) return null;
|
||||
if (
|
||||
relation.childColumns.length !== 1
|
||||
|| relation.parentColumns.length !== 1
|
||||
|| relation.childColumns[0] !== exception.childColumn
|
||||
|| relation.parentTable !== exception.parentTable
|
||||
|| relation.parentColumns[0] !== exception.parentColumn
|
||||
) {
|
||||
throw new Error(`Tenant foreign key exception definition no longer matches ${key}`);
|
||||
}
|
||||
return exception;
|
||||
}
|
||||
|
||||
export function summarizeTenantForeignKeySchema(inputRelations) {
|
||||
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
|
||||
const keys = new Set(relations.map(tenantForeignKeyRelationKey));
|
||||
const missingExceptions = [...TENANT_FOREIGN_KEY_EXCEPTIONS.keys()].filter(key => !keys.has(key));
|
||||
const exceptionRelations = relations.filter(relation => exceptionFor(relation));
|
||||
const unvalidatedRelations = relations
|
||||
.filter(relation => !relation.validated)
|
||||
.map(tenantForeignKeyRelationKey);
|
||||
const schemaSha256 = tenantForeignKeySchemaSha256(relations);
|
||||
const schemaMatches = relations.length === EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT
|
||||
&& schemaSha256 === EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256
|
||||
&& missingExceptions.length === 0
|
||||
&& unvalidatedRelations.length === 0;
|
||||
return {
|
||||
relationCount: relations.length,
|
||||
expectedRelationCount: EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT,
|
||||
schemaSha256,
|
||||
expectedSchemaSha256: EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256,
|
||||
schemaMatches,
|
||||
exceptionCount: exceptionRelations.length,
|
||||
expectedExceptionCount: TENANT_FOREIGN_KEY_EXCEPTIONS.size,
|
||||
missingExceptions,
|
||||
unvalidatedRelations,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTenantForeignKeyRelations(queryable) {
|
||||
const result = await queryable.query(RELATION_QUERY);
|
||||
return result.rows.map(normalizeTenantForeignKeyRelation);
|
||||
}
|
||||
|
||||
function quoteIdentifier(value) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function quoteLiteral(value) {
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function violationPredicate(relation) {
|
||||
const exception = exceptionFor(relation);
|
||||
if (!exception) return 'child.tenant_id is distinct from parent.tenant_id';
|
||||
if (exception.mode === 'global-or-same-tenant-parent') {
|
||||
return 'parent.tenant_id is not null and child.tenant_id is distinct from parent.tenant_id';
|
||||
}
|
||||
if (exception.mode === 'platform-source-or-same-tenant-parent') {
|
||||
return "child.tenant_id is distinct from parent.tenant_id and parent.source_scope is distinct from 'platform'";
|
||||
}
|
||||
throw new Error(`Unsupported tenant foreign key exception mode: ${exception.mode}`);
|
||||
}
|
||||
|
||||
export function buildTenantForeignKeyViolationQuery(inputRelations) {
|
||||
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
|
||||
if (relations.length === 0) {
|
||||
return `select null::text as "relationKey", null::text as "childTenantId", null::text as "parentTenantId" where false`;
|
||||
}
|
||||
return relations.map(relation => {
|
||||
if (relation.childColumns.length !== relation.parentColumns.length || relation.childColumns.length === 0) {
|
||||
throw new Error(`Invalid tenant foreign key shape: ${tenantForeignKeyRelationKey(relation)}`);
|
||||
}
|
||||
const join = relation.childColumns.map((childColumn, index) => (
|
||||
`parent.${quoteIdentifier(relation.parentColumns[index])} = child.${quoteIdentifier(childColumn)}`
|
||||
)).join(' and ');
|
||||
return `(
|
||||
select ${quoteLiteral(tenantForeignKeyRelationKey(relation))}::text as "relationKey",
|
||||
child.tenant_id::text as "childTenantId",
|
||||
parent.tenant_id::text as "parentTenantId"
|
||||
from public.${quoteIdentifier(relation.childTable)} child
|
||||
join public.${quoteIdentifier(relation.parentTable)} parent on ${join}
|
||||
where ${violationPredicate(relation)}
|
||||
limit 1
|
||||
)`;
|
||||
}).join('\nunion all\n');
|
||||
}
|
||||
|
||||
export async function auditTenantForeignKeyData(client, inputRelations, statementTimeoutMs = 120_000) {
|
||||
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
|
||||
const timeout = Math.max(1_000, Math.min(900_000, Number(statementTimeoutMs) || 120_000));
|
||||
await client.query('begin read only');
|
||||
try {
|
||||
await client.query(`set local statement_timeout = '${timeout}ms'`);
|
||||
await client.query(`set local lock_timeout = '5s'`);
|
||||
const result = await client.query(buildTenantForeignKeyViolationQuery(relations));
|
||||
await client.query('commit');
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantForeignKeyExceptions() {
|
||||
return [...TENANT_FOREIGN_KEY_EXCEPTIONS.entries()].map(([relationKey, definition]) => ({
|
||||
relationKey,
|
||||
...definition,
|
||||
}));
|
||||
}
|
||||
@@ -86,7 +86,7 @@ function runAuditAlertWorkerOnce() {
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '20',
|
||||
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '1000',
|
||||
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS: '30',
|
||||
WORKER_PLATFORM_AUDIT_ALERT_ID: 'platform-usage-overage-alert-test',
|
||||
},
|
||||
|
||||
@@ -58,9 +58,9 @@ for (const item of lines) {
|
||||
|
||||
if (
|
||||
/smoke:taro:h5:interaction/.test(text) &&
|
||||
hasAny(text, [/26 项/, /租户后台六个主模块/, /平台后台四个主模块/, /租户题库内容\/财务、平台租户\/账务中心/])
|
||||
hasAny(text, [/26 项/, /32 项/, /租户后台六个主模块/, /平台后台四个主模块/, /租户题库内容\/财务、平台租户\/账务中心/])
|
||||
) {
|
||||
failures.push(`${item.file}:${item.line} H5 交互烟测已经覆盖 32 项和后台真实写操作,不能回退到旧描述:${text}`);
|
||||
failures.push(`${item.file}:${item.line} H5 交互烟测已经覆盖 33 项、运行时竞态探针和后台真实写操作,不能回退到旧描述:${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const safeBaseEnv = {
|
||||
const safeApiEnv = {
|
||||
...safeBaseEnv,
|
||||
CORS_ORIGIN: 'https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com',
|
||||
CORS_TENANT_DOMAINS_ENABLED: 'true',
|
||||
AUTH_SMS_PROVIDER: 'aliyun-pnvs',
|
||||
AUTH_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef',
|
||||
AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl',
|
||||
@@ -73,6 +74,28 @@ assert.match(
|
||||
'API config should require PNVS for production SMS',
|
||||
);
|
||||
|
||||
const unsafeApiTenantCorsDisabled = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
CORS_TENANT_DOMAINS_ENABLED: 'false',
|
||||
});
|
||||
assert.notEqual(unsafeApiTenantCorsDisabled.status, 0, 'production API config should require dynamic tenant CORS');
|
||||
assert.match(
|
||||
unsafeApiTenantCorsDisabled.output,
|
||||
/CORS_TENANT_DOMAINS_ENABLED must be true in production/,
|
||||
'API config should fail closed when dynamic tenant CORS is disabled',
|
||||
);
|
||||
|
||||
const unsafeApiCorsPath = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
CORS_ORIGIN: 'https://platform-admin.gongxue100.com/app',
|
||||
});
|
||||
assert.notEqual(unsafeApiCorsPath.status, 0, 'production API config should reject non-Origin CORS URLs');
|
||||
assert.match(
|
||||
unsafeApiCorsPath.output,
|
||||
/CORS_ORIGIN must contain only production HTTPS origins without paths/,
|
||||
'API config should reject CORS entries with URL paths',
|
||||
);
|
||||
|
||||
const unsafeApiTraditionalSmsProvider = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
AUTH_SMS_PROVIDER: 'aliyun',
|
||||
|
||||
@@ -3,7 +3,16 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { gateChecks, requiredAttestations } from './production-launch-gate.js';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
gateChecks,
|
||||
parseArgs,
|
||||
productionUrlFailure,
|
||||
requiredAttestations,
|
||||
validateEvidence,
|
||||
weappAttestations,
|
||||
weappGateChecks,
|
||||
} from './production-launch-gate.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, 'scripts', 'production-launch-gate.js');
|
||||
@@ -38,23 +47,189 @@ function sampleSummary(summarySpec) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function nestedSummaryPayload(summary) {
|
||||
return { summary };
|
||||
}
|
||||
|
||||
function pocketBaseDryRunPayload(summary) {
|
||||
return {
|
||||
migrationProfile: summary.migrationProfile,
|
||||
summary: {
|
||||
blockers: summary.blocker,
|
||||
warnings: summary.warning,
|
||||
},
|
||||
migrationReadiness: {
|
||||
requiredCollections: Array.from({ length: 3 }, (_, index) => ({ collection: `required-${index}`, present: true })),
|
||||
criticalFieldCoverage: Array.from({ length: 3 }, (_, index) => ({ collection: `coverage-${index}`, present: true })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function productionEvidencePayload(spec, summary) {
|
||||
if (spec.id === 'db.migration-history') return summary;
|
||||
if (spec.id === 'auth.platform-admin-bootstrap') {
|
||||
const { identityMatches: _identityMatches, ...artifact } = summary;
|
||||
return artifact;
|
||||
}
|
||||
if (spec.id === 'backup.restore-drill') return summary;
|
||||
if (spec.id === 'taro.supply-chain') {
|
||||
const reviewedEdges = [
|
||||
{ parent: '@tarojs/components', dependency: 'swiper', declared: '11.1.15' },
|
||||
{ parent: '@tarojs/components-react', dependency: 'swiper', declared: '11.1.15' },
|
||||
{ parent: '@tarojs/plugin-platform-h5', dependency: 'lodash-es', declared: '4.17.21' },
|
||||
{ parent: '@tarojs/taro-h5', dependency: 'lodash-es', declared: '4.17.21' },
|
||||
];
|
||||
return {
|
||||
schemaVersion: summary.schemaVersion,
|
||||
status: summary.status,
|
||||
securedBundleDependencies: summary.securedBundleDependencies,
|
||||
npmLs: {
|
||||
edges: reviewedEdges,
|
||||
},
|
||||
audit: { counts: summary.audit.counts },
|
||||
riskBoundary: {
|
||||
appliesTo: 'Taro 4.2.0 CLI and build toolchain',
|
||||
controls: Array.from({ length: summary.riskControlCount }, (_, index) => `control-${index}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsonArtifactPayload(spec, summary) {
|
||||
const productionPayload = productionEvidencePayload(spec, summary);
|
||||
if (productionPayload) return productionPayload;
|
||||
if (spec.id === 'readiness.production.env' || spec.id === 'readiness.production.db') return nestedSummaryPayload(summary);
|
||||
if (spec.id === 'postgres.tuning-evidence' || spec.id === 'auth.sms-pnvs-diagnostics' || spec.id === 'auth.sms-pnvs-remote-smoke') return summary;
|
||||
if (spec.id === 'migration.pb-production-dry-run') return pocketBaseDryRunPayload(summary);
|
||||
if (spec.id === 'api.launch-persona-smoke') return summary;
|
||||
if (spec.id === 'api.dynamic-tenant-cors-smoke' || spec.id === 'db.tenant-foreign-key-audit') return summary;
|
||||
if (spec.id === 'taro.h5-static-smoke' || spec.id === 'taro.h5-release-guardrails' || spec.id === 'security.repo-scan') return nestedSummaryPayload(summary);
|
||||
if (spec.id === 'taro.h5-interaction-smoke') return { summary: { fail: summary.fail, pass: summary.pass }, mockApi: summary.mockApi };
|
||||
if (spec.id === 'taro.h5-release-manifest') return { ...nestedSummaryPayload(summary), portals: [] };
|
||||
if (spec.id === 'taro.weapp-release-guardrails') return nestedSummaryPayload(summary);
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeCheckArtifact(spec, summary, artifactPath) {
|
||||
const jsonPayload = jsonArtifactPayload(spec, summary);
|
||||
if (jsonPayload) {
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(jsonPayload, null, 2)}\n`, 'utf8');
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(artifactPath, `TIKU_LAUNCH_GATE_SUCCESS:${spec.id}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function rewriteArtifact(tempDir, item, payload) {
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
}
|
||||
|
||||
function createEvidence(tempDir, overrides = {}) {
|
||||
const artifactDir = path.join(tempDir, 'launch-artifacts');
|
||||
fs.mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const checks = gateChecks.map(spec => {
|
||||
const artifact = `launch-artifacts/${spec.id}.log`;
|
||||
fs.writeFileSync(path.join(tempDir, artifact), `[PASS] ${spec.id}\n`, 'utf8');
|
||||
const artifactPath = path.join(tempDir, artifact);
|
||||
const summary = sampleSummary(spec.summary);
|
||||
if (spec.id === 'db.migration-history') {
|
||||
const migrationFiles = fs.readdirSync(path.join(repoRoot, 'supabase', 'migrations'))
|
||||
.filter(file => file.endsWith('.sql'))
|
||||
.sort();
|
||||
const latestMigration = /^(\d+)_/.exec(migrationFiles.at(-1) || '')?.[1];
|
||||
summary.latestRepositoryMigration = latestMigration;
|
||||
summary.latestAppliedMigration = latestMigration;
|
||||
}
|
||||
if (spec.id === 'auth.platform-admin-bootstrap') {
|
||||
const identityHash = crypto.createHash('sha256').update('platform-admin-auth-user').digest('hex');
|
||||
summary.adminUserIdSha256 = identityHash;
|
||||
summary.authSmokeExpectedUserIdSha256 = identityHash;
|
||||
summary.dryRunArtifactSha256 = crypto.createHash('sha256').update('platform-admin-dry-run-artifact').digest('hex');
|
||||
summary.applyArtifactSha256 = crypto.createHash('sha256').update('platform-admin-apply-artifact').digest('hex');
|
||||
summary.authSmokeArtifactSha256 = crypto.createHash('sha256').update('platform-admin-auth-smoke-artifact').digest('hex');
|
||||
summary.auditArtifactSha256 = crypto.createHash('sha256').update('platform-admin-audit-artifact').digest('hex');
|
||||
}
|
||||
if (spec.id === 'backup.restore-drill') {
|
||||
summary.verificationArtifactSha256 = crypto.createHash('sha256').update('backup-restore-verification-artifact').digest('hex');
|
||||
}
|
||||
if (spec.id === 'taro.h5-release-manifest') {
|
||||
const releaseRoot = path.join(tempDir, 'candidate');
|
||||
const portalDirs = [
|
||||
['student', 'apps/taro/dist/h5-student'],
|
||||
['tenant-admin', 'apps/taro/dist/h5-tenant-admin'],
|
||||
['platform-admin', 'apps/taro/dist/h5-platform-admin'],
|
||||
];
|
||||
const portals = portalDirs.map(([portal, relativeDir]) => {
|
||||
const dir = path.join(releaseRoot, relativeDir);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'index.html'), `<div id="app">${portal}</div>\n`, 'utf8');
|
||||
const content = fs.readFileSync(path.join(dir, 'index.html'));
|
||||
const treeHash = crypto.createHash('sha256')
|
||||
.update('index.html\0')
|
||||
.update(String(content.length))
|
||||
.update('\0')
|
||||
.update(content)
|
||||
.update('\0')
|
||||
.digest('hex');
|
||||
return { portal, dist: { treeSha256: treeHash } };
|
||||
});
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify({ summary, portals }, null, 2)}\n`, 'utf8');
|
||||
} else if (spec.id === 'performance.tenant-students-100k') {
|
||||
const caseItem = (id, p95, indexes) => ({
|
||||
id,
|
||||
latencyMs: { p95 },
|
||||
explain: { summary: { indexes } },
|
||||
});
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'tenant-student-capacity',
|
||||
safety: { databaseEnvironment: summary.databaseEnvironment },
|
||||
fixture: summary.fixture,
|
||||
config: { deepCursorApproximateOffset: summary.deepCursorApproximateOffset },
|
||||
cases: [
|
||||
caseItem('first-page', summary.firstPageP95Ms, ['idx_memberships_student_keyset_page']),
|
||||
caseItem('deep-cursor', summary.deepCursorP95Ms, ['idx_memberships_student_keyset_page']),
|
||||
caseItem('name-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']),
|
||||
caseItem('phone-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']),
|
||||
caseItem('email-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']),
|
||||
],
|
||||
cleanup: {
|
||||
cleanupVerified: summary.cleanupVerified,
|
||||
remaining: { tenants: 0, platformUsers: 0, memberships: 0, profiles: 0 },
|
||||
},
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
} else {
|
||||
writeCheckArtifact(spec, summary, artifactPath);
|
||||
}
|
||||
return {
|
||||
id: spec.id,
|
||||
status: 'pass',
|
||||
command: `npm run ${spec.commandIncludes} -- recorded-for-launch-gate`,
|
||||
completedAt: isoNow(),
|
||||
artifact,
|
||||
summary: sampleSummary(spec.summary),
|
||||
artifactSha256: crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'),
|
||||
summary,
|
||||
};
|
||||
});
|
||||
|
||||
const readinessDbCheck = checks.find(item => item.id === 'readiness.production.db');
|
||||
const migrationHistoryCheck = checks.find(item => item.id === 'db.migration-history');
|
||||
const readinessDbArtifactPath = path.join(tempDir, readinessDbCheck.artifact);
|
||||
migrationHistoryCheck.summary.readinessArtifactSha256 = crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(readinessDbArtifactPath))
|
||||
.digest('hex');
|
||||
const migrationHistoryArtifactPath = path.join(tempDir, migrationHistoryCheck.artifact);
|
||||
writeCheckArtifact(
|
||||
gateChecks.find(spec => spec.id === migrationHistoryCheck.id),
|
||||
migrationHistoryCheck.summary,
|
||||
migrationHistoryArtifactPath,
|
||||
);
|
||||
migrationHistoryCheck.artifactSha256 = crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(migrationHistoryArtifactPath))
|
||||
.digest('hex');
|
||||
|
||||
const attestations = requiredAttestations.map(spec => ({
|
||||
id: spec.id,
|
||||
status: 'approved',
|
||||
@@ -66,6 +241,7 @@ function createEvidence(tempDir, overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
environment: 'production',
|
||||
releaseTargets: ['h5'],
|
||||
commit: '52cef9fabcd1234567890abcdef1234567890abc',
|
||||
target: {
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
@@ -83,6 +259,9 @@ function runGate(evidence, options = {}) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-'));
|
||||
const evidencePath = path.join(tempDir, 'evidence.json');
|
||||
const finalEvidence = typeof evidence === 'function' ? evidence(tempDir) : evidence;
|
||||
const deployReleaseRoot = options.deployReleaseRoot === '__TEMP_CANDIDATE__'
|
||||
? path.join(tempDir, 'candidate')
|
||||
: (options.deployReleaseRoot || '');
|
||||
fs.writeFileSync(evidencePath, JSON.stringify(finalEvidence, null, 2), 'utf8');
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--evidence', evidencePath, '--json', ...(options.args || [])], {
|
||||
@@ -95,6 +274,8 @@ function runGate(evidence, options = {}) {
|
||||
ComSpec: process.env.ComSpec || '',
|
||||
TEMP: process.env.TEMP || os.tmpdir(),
|
||||
TMP: process.env.TMP || os.tmpdir(),
|
||||
DEPLOY_COMMIT_SHA: options.deployCommitSha || '',
|
||||
DEPLOY_RELEASE_ROOT: deployReleaseRoot,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,10 +284,472 @@ function runGate(evidence, options = {}) {
|
||||
return { ...result, payload };
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function liveH5Fixture(tempDir) {
|
||||
const portalContents = {
|
||||
student: {
|
||||
targetKey: 'studentH5Url',
|
||||
baseUrl: 'https://student.gongxue100.com',
|
||||
appPath: '/js/app.student.js',
|
||||
app: 'console.log("student");\n',
|
||||
},
|
||||
'tenant-admin': {
|
||||
targetKey: 'tenantAdminH5Url',
|
||||
baseUrl: 'https://admin.gongxue100.com',
|
||||
appPath: '/js/app.tenant.js',
|
||||
app: 'console.log("tenant-admin");\n',
|
||||
},
|
||||
'platform-admin': {
|
||||
targetKey: 'platformAdminH5Url',
|
||||
baseUrl: 'https://console.gongxue100.com',
|
||||
appPath: '/js/app.platform.js',
|
||||
app: 'console.log("platform-admin");\n',
|
||||
},
|
||||
};
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.liveH5 = { portals: [] };
|
||||
const responses = new Map();
|
||||
|
||||
for (const [portal, fixture] of Object.entries(portalContents)) {
|
||||
const index = `<!doctype html><html><head><script defer src="${fixture.appPath}"></script></head><body><div id="app"></div></body></html>`;
|
||||
const runtime = `${JSON.stringify({ portal, apiBaseUrl: evidence.target.apiBaseUrl })}\n`;
|
||||
evidence.target[fixture.targetKey] = fixture.baseUrl;
|
||||
evidence.liveH5.portals.push({
|
||||
portal,
|
||||
indexSha256: sha256(index),
|
||||
appPath: fixture.appPath,
|
||||
appSha256: sha256(fixture.app),
|
||||
});
|
||||
responses.set(`${fixture.baseUrl}/index.html`, { body: index, contentType: 'text/html; charset=utf-8' });
|
||||
responses.set(`${fixture.baseUrl}/runtime-config.json`, { body: runtime, contentType: 'application/json' });
|
||||
responses.set(`${fixture.baseUrl}${fixture.appPath}`, { body: fixture.app, contentType: 'application/javascript' });
|
||||
}
|
||||
|
||||
return { evidence, responses };
|
||||
}
|
||||
|
||||
function liveH5ManifestFixture(tempDir) {
|
||||
const fixture = liveH5Fixture(tempDir);
|
||||
const candidateRoot = path.join(tempDir, 'candidate');
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
portals: [],
|
||||
};
|
||||
|
||||
for (const portalItem of fixture.evidence.liveH5.portals) {
|
||||
const portalDir = path.join(
|
||||
candidateRoot,
|
||||
'apps',
|
||||
'taro',
|
||||
'dist',
|
||||
portalItem.portal === 'student' ? 'h5-student' : `h5-${portalItem.portal}`,
|
||||
);
|
||||
const baseUrl = portalItem.portal === 'student'
|
||||
? 'https://student.gongxue100.com'
|
||||
: portalItem.portal === 'tenant-admin'
|
||||
? 'https://admin.gongxue100.com'
|
||||
: 'https://console.gongxue100.com';
|
||||
const index = fixture.responses.get(`${baseUrl}/index.html`).body;
|
||||
const app = fixture.responses.get(`${baseUrl}${portalItem.appPath}`).body;
|
||||
const appFile = path.join(portalDir, `.${portalItem.appPath}`);
|
||||
fs.mkdirSync(path.dirname(appFile), { recursive: true });
|
||||
fs.writeFileSync(path.join(portalDir, 'index.html'), index, 'utf8');
|
||||
fs.writeFileSync(appFile, app, 'utf8');
|
||||
manifest.portals.push({
|
||||
portal: portalItem.portal,
|
||||
dist: {
|
||||
dir: '../../must-not-be-used',
|
||||
indexSha256: sha256(index),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const manifestPath = path.join(tempDir, 'launch-artifacts', 'taro-h5-release-manifest.json');
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
fixture.evidence.liveH5 = {
|
||||
releaseManifestArtifact: path.relative(tempDir, manifestPath),
|
||||
releaseManifestSha256: sha256(fs.readFileSync(manifestPath)),
|
||||
};
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function fixtureFetch(responses) {
|
||||
return async url => {
|
||||
const key = String(url);
|
||||
const item = responses.get(key);
|
||||
if (!item) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
||||
return new Response(item.body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': item.contentType },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function removeTempDir(tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const safe = runGate(tempDir => createEvidence(tempDir));
|
||||
assert.equal(safe.status, 0, `complete launch evidence should pass: ${safe.stdout} ${safe.stderr}`);
|
||||
assert.equal(safe.payload.summary?.blocker, 0, 'complete launch evidence should have no blockers');
|
||||
|
||||
const evidenceTemplate = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docs', 'refactor', 'production-launch-evidence.template.json'), 'utf8'));
|
||||
const templateCheckIds = evidenceTemplate.checks.map(item => item.id);
|
||||
const coreGateCheckIds = gateChecks.map(item => item.id);
|
||||
assert.deepEqual(templateCheckIds, coreGateCheckIds, 'template core check IDs must exactly match gateChecks in order');
|
||||
assert.equal(new Set(templateCheckIds).size, templateCheckIds.length, 'template core check IDs must be unique');
|
||||
assert.ok(coreGateCheckIds.includes('worker.platform-billing'), 'platform billing worker must be a core launch check');
|
||||
assert.ok(coreGateCheckIds.includes('worker.platform-dunning'), 'platform dunning worker must be a core launch check');
|
||||
for (const id of ['db.migration-history', 'auth.platform-admin-bootstrap', 'backup.restore-drill', 'taro.supply-chain']) {
|
||||
assert.ok(coreGateCheckIds.includes(id), `${id} must be a core launch check`);
|
||||
}
|
||||
for (const item of evidenceTemplate.checks) {
|
||||
const spec = gateChecks.find(candidate => candidate.id === item.id);
|
||||
if (typeof spec?.artifactSummary === 'function') continue;
|
||||
assert.ok(
|
||||
String(item.command || '').includes(`TIKU_LAUNCH_GATE_SUCCESS:${item.id}`),
|
||||
`log-backed template check ${item.id} must append its explicit success sentinel`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
evidenceTemplate.checks.filter(item => item.artifact && !item.artifactSha256).length,
|
||||
0,
|
||||
'every template check with an artifact must include artifactSha256',
|
||||
);
|
||||
assert.ok(evidenceTemplate.liveH5?.releaseManifestSha256, 'template must document the strict live H5 release manifest hash');
|
||||
|
||||
for (const rejectedUrl of [
|
||||
'http://api.gongxue100.com',
|
||||
'https://localhost',
|
||||
'https://127.0.0.1',
|
||||
'https://portal.test',
|
||||
'https://portal.example',
|
||||
'https://portal.example.com',
|
||||
'https://replace-with-domain.invalid',
|
||||
'https://replace-with-real-domain.com',
|
||||
]) {
|
||||
assert.ok(productionUrlFailure(rejectedUrl), `production URL validation must reject ${rejectedUrl}`);
|
||||
}
|
||||
assert.equal(productionUrlFailure('https://student.gongxue100.com'), '', 'real HTTPS production URL should be accepted');
|
||||
assert.equal(parseArgs(['node', 'gate', '--verify-live-h5']).verifyLiveH5, true, 'CLI should explicitly enable strict live H5 validation');
|
||||
assert.equal(
|
||||
parseArgs(['node', 'gate', '--verify-live-h5', '--no-verify-live-h5']).verifyLiveH5,
|
||||
false,
|
||||
'CLI should allow deployment wrappers to explicitly disable inherited live validation',
|
||||
);
|
||||
|
||||
const placeholderTarget = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.target.studentH5Url = 'https://student.example.com';
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(placeholderTarget.status, 0, 'placeholder target URL should fail launch gate');
|
||||
assert.ok(placeholderTarget.payload.checks?.some(item => item.id === 'target.studentH5Url' && item.status === 'blocker'));
|
||||
|
||||
{
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-'));
|
||||
try {
|
||||
const fixture = liveH5Fixture(tempDir);
|
||||
const checks = await validateEvidence(fixture.evidence, {
|
||||
evidencePath: path.join(tempDir, 'evidence.json'),
|
||||
allowStale: false,
|
||||
maxAgeDays: 14,
|
||||
verifyLiveH5: true,
|
||||
liveTimeoutMs: 5_000,
|
||||
fetchImpl: fixtureFetch(fixture.responses),
|
||||
deployReleaseRoot: path.join(tempDir, 'candidate'),
|
||||
});
|
||||
assert.equal(checks.some(item => item.status === 'blocker'), false, JSON.stringify(checks.filter(item => item.status === 'blocker'), null, 2));
|
||||
assert.equal(checks.filter(item => /^live_h5\..+\.app_hash$/.test(item.id) && item.status === 'pass').length, 3);
|
||||
} finally {
|
||||
removeTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const tempDir = fs.mkdtempSync(path.join(repoRoot, '.tmp-launch-gate-manifest-'));
|
||||
try {
|
||||
const fixture = liveH5ManifestFixture(tempDir);
|
||||
const checks = await validateEvidence(fixture.evidence, {
|
||||
evidencePath: path.join(tempDir, 'evidence.json'),
|
||||
allowStale: false,
|
||||
maxAgeDays: 14,
|
||||
verifyLiveH5: true,
|
||||
liveTimeoutMs: 5_000,
|
||||
fetchImpl: fixtureFetch(fixture.responses),
|
||||
deployReleaseRoot: path.join(tempDir, 'candidate'),
|
||||
});
|
||||
assert.equal(checks.some(item => item.status === 'blocker'), false, JSON.stringify(checks.filter(item => item.status === 'blocker'), null, 2));
|
||||
assert.equal(checks.filter(item => /^live_h5\..+\.candidate_files$/.test(item.id) && item.status === 'pass').length, 3);
|
||||
} finally {
|
||||
removeTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-'));
|
||||
try {
|
||||
const fixture = liveH5Fixture(tempDir);
|
||||
const studentApp = fixture.evidence.liveH5.portals.find(item => item.portal === 'student');
|
||||
fixture.responses.set('https://student.gongxue100.com/js/app.student.js', {
|
||||
body: 'console.log("stale-production-bundle");\n',
|
||||
contentType: 'application/javascript',
|
||||
});
|
||||
const checks = await validateEvidence(fixture.evidence, {
|
||||
evidencePath: path.join(tempDir, 'evidence.json'),
|
||||
allowStale: false,
|
||||
maxAgeDays: 14,
|
||||
verifyLiveH5: true,
|
||||
liveTimeoutMs: 5_000,
|
||||
fetchImpl: fixtureFetch(fixture.responses),
|
||||
});
|
||||
assert.ok(studentApp.appSha256);
|
||||
assert.ok(checks.some(item => item.id === 'live_h5.student.app_hash' && item.status === 'blocker'));
|
||||
} finally {
|
||||
removeTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-'));
|
||||
try {
|
||||
const fixture = liveH5Fixture(tempDir);
|
||||
fixture.responses.set('https://admin.gongxue100.com/runtime-config.json', {
|
||||
body: JSON.stringify({ portal: 'student', apiBaseUrl: 'https://api.other-domain.com' }),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
const checks = await validateEvidence(fixture.evidence, {
|
||||
evidencePath: path.join(tempDir, 'evidence.json'),
|
||||
allowStale: false,
|
||||
maxAgeDays: 14,
|
||||
verifyLiveH5: true,
|
||||
liveTimeoutMs: 5_000,
|
||||
fetchImpl: fixtureFetch(fixture.responses),
|
||||
});
|
||||
assert.ok(checks.some(item => item.id === 'live_h5.tenant-admin.runtime_portal' && item.status === 'blocker'));
|
||||
assert.ok(checks.some(item => item.id === 'live_h5.tenant-admin.runtime_api' && item.status === 'blocker'));
|
||||
} finally {
|
||||
removeTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
const wrongCommit = runGate(tempDir => createEvidence(tempDir), {
|
||||
deployCommitSha: 'aaaaaaaaaaaa',
|
||||
deployReleaseRoot: path.join(os.tmpdir(), 'missing-release'),
|
||||
});
|
||||
assert.notEqual(wrongCommit.status, 0, 'evidence for another commit should fail launch gate');
|
||||
assert.ok(wrongCommit.payload.checks?.some(item => item.id === 'evidence.commit_match' && item.status === 'blocker'));
|
||||
|
||||
const matchingCandidate = runGate(tempDir => createEvidence(tempDir), {
|
||||
deployCommitSha: '52cef9fabcd1',
|
||||
deployReleaseRoot: '',
|
||||
});
|
||||
assert.notEqual(matchingCandidate.status, 0, 'deployment gate without a candidate release root should fail');
|
||||
assert.ok(matchingCandidate.payload.checks?.some(item => item.id === 'evidence.release_root' && item.status === 'blocker'));
|
||||
|
||||
const candidateTreeMatch = runGate(tempDir => createEvidence(tempDir), {
|
||||
deployCommitSha: '52cef9fabcd1',
|
||||
deployReleaseRoot: '__TEMP_CANDIDATE__',
|
||||
});
|
||||
assert.equal(candidateTreeMatch.status, 0, `matching candidate release should pass: ${candidateTreeMatch.stdout}`);
|
||||
|
||||
const candidateTreeMismatch = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
fs.appendFileSync(path.join(tempDir, 'candidate/apps/taro/dist/h5-student/index.html'), 'tampered\n', 'utf8');
|
||||
return evidence;
|
||||
}, {
|
||||
deployCommitSha: '52cef9fabcd1',
|
||||
deployReleaseRoot: '__TEMP_CANDIDATE__',
|
||||
});
|
||||
assert.notEqual(candidateTreeMismatch.status, 0, 'candidate release that differs from the manifest should fail');
|
||||
assert.ok(candidateTreeMismatch.payload.checks?.some(item => item.id === 'check.taro.h5-release-manifest.release_tree' && item.status === 'blocker'));
|
||||
|
||||
const tamperedArtifact = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'auth.remote-smoke');
|
||||
fs.appendFileSync(path.join(tempDir, item.artifact), 'tampered\n', 'utf8');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(tamperedArtifact.status, 0, 'tampered artifact should fail launch gate');
|
||||
assert.ok(tamperedArtifact.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact_hash' && item.status === 'blocker'));
|
||||
|
||||
const missingArtifactHash = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
delete evidence.checks.find(check => check.id === 'auth.remote-smoke').artifactSha256;
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingArtifactHash.status, 0, 'missing artifact hash should fail launch gate');
|
||||
assert.ok(missingArtifactHash.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact_hash' && item.status === 'blocker'));
|
||||
|
||||
const forgedPassLog = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'worker.platform-billing');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
fs.writeFileSync(artifactPath, '[PASS] worker.platform-billing\n', 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(forgedPassLog.status, 0, 'a forged generic PASS log must not satisfy a log-backed check');
|
||||
assert.ok(
|
||||
forgedPassLog.payload.checks?.some(item => item.id === 'check.worker.platform-billing.artifact_success' && item.status === 'blocker'),
|
||||
'missing check-specific log success sentinel must be reported as a blocker',
|
||||
);
|
||||
|
||||
const forgedJsonSummary = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'readiness.production.env');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
|
||||
payload.summary.blocker = 1;
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(forgedJsonSummary.status, 0, 'evidence summary must not override a failing structured artifact');
|
||||
assert.ok(
|
||||
forgedJsonSummary.payload.checks?.some(item => item.id === 'check.readiness.production.env.artifact_summary' && item.status === 'blocker'),
|
||||
'structured artifact mismatch must be reported as a blocker',
|
||||
);
|
||||
|
||||
const invalidJsonArtifact = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'readiness.production.db');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
fs.writeFileSync(artifactPath, '{not-json}\n', 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(invalidJsonArtifact.status, 0, 'invalid JSON artifact must not satisfy a structured check');
|
||||
assert.ok(
|
||||
invalidJsonArtifact.payload.checks?.some(item => item.id === 'check.readiness.production.db.artifact_json' && item.status === 'blocker'),
|
||||
'invalid structured artifact must be reported as a JSON blocker',
|
||||
);
|
||||
|
||||
const unverifiedJsonSummaryField = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'readiness.production.env');
|
||||
item.summary.operatorAssertion = 'pass';
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(unverifiedJsonSummaryField.status, 0, 'structured checks must reject summary fields that are not derived from the artifact gate contract');
|
||||
assert.ok(
|
||||
unverifiedJsonSummaryField.payload.checks?.some(item => item.id === 'check.readiness.production.env.artifact_summary' && item.status === 'blocker'),
|
||||
'unverified structured summary fields must be reported as artifact summary blockers',
|
||||
);
|
||||
|
||||
const staleMigrationArtifact = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'db.migration-history');
|
||||
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
|
||||
payload.latestAppliedMigration = '202607120018';
|
||||
rewriteArtifact(tempDir, item, payload);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(staleMigrationArtifact.status, 0, 'migration evidence older than the repository latest migration must fail');
|
||||
assert.ok(staleMigrationArtifact.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const forgedMigrationReadinessHash = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'db.migration-history');
|
||||
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
|
||||
payload.readinessArtifactSha256 = 'not-a-sha256';
|
||||
rewriteArtifact(tempDir, item, payload);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(forgedMigrationReadinessHash.status, 0, 'migration evidence must bind the readiness artifact by SHA-256');
|
||||
assert.ok(forgedMigrationReadinessHash.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const mismatchedMigrationReadinessArtifact = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'readiness.production.db');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
fs.appendFileSync(artifactPath, '\nchanged-after-migration-summary\n', 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(mismatchedMigrationReadinessArtifact.status, 0, 'migration summary must fail when the bound readiness artifact changes');
|
||||
assert.ok(mismatchedMigrationReadinessArtifact.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const mismatchedPlatformAdminIdentity = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'auth.platform-admin-bootstrap');
|
||||
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
|
||||
payload.authSmokeExpectedUserIdSha256 = crypto.createHash('sha256').update('different-auth-user').digest('hex');
|
||||
rewriteArtifact(tempDir, item, payload);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(mismatchedPlatformAdminIdentity.status, 0, 'platform admin bootstrap and Auth smoke identities must match');
|
||||
assert.ok(mismatchedPlatformAdminIdentity.payload.checks?.some(item => item.id === 'check.auth.platform-admin-bootstrap.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const unverifiedRestoreDrill = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'backup.restore-drill');
|
||||
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
|
||||
payload.integrityVerified = false;
|
||||
rewriteArtifact(tempDir, item, payload);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(unverifiedRestoreDrill.status, 0, 'restore drill without verified integrity must fail');
|
||||
assert.ok(unverifiedRestoreDrill.payload.checks?.some(item => item.id === 'check.backup.restore-drill.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const forgedRestoreVerificationHash = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'backup.restore-drill');
|
||||
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
|
||||
payload.verificationArtifactSha256 = 'not-a-sha256';
|
||||
rewriteArtifact(tempDir, item, payload);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(forgedRestoreVerificationHash.status, 0, 'restore drill must bind its verification output by SHA-256');
|
||||
assert.ok(forgedRestoreVerificationHash.payload.checks?.some(item => item.id === 'check.backup.restore-drill.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const regressedTaroSupplyChain = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'taro.supply-chain');
|
||||
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
|
||||
payload.securedBundleDependencies.swiper = '11.1.15';
|
||||
payload.audit.counts.critical = 4;
|
||||
rewriteArtifact(tempDir, item, payload);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(regressedTaroSupplyChain.status, 0, 'Taro supply-chain bundle or vulnerability regression must fail');
|
||||
assert.ok(regressedTaroSupplyChain.payload.checks?.some(item => item.id === 'check.taro.supply-chain.artifact_summary' && item.status === 'blocker'));
|
||||
|
||||
const missingWeappEvidence = runGate(tempDir => createEvidence(tempDir, { releaseTargets: ['h5', 'weapp'] }));
|
||||
assert.notEqual(missingWeappEvidence.status, 0, 'WeApp release target without production evidence should fail launch gate');
|
||||
assert.ok(missingWeappEvidence.payload.checks?.some(item => item.id === 'check.taro.build.weapp-student' && item.status === 'blocker'));
|
||||
|
||||
const completeWeappEvidence = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir, { releaseTargets: ['h5', 'weapp'] });
|
||||
for (const spec of weappGateChecks) {
|
||||
const artifact = `launch-artifacts/${spec.id}.log`;
|
||||
const artifactPath = path.join(tempDir, artifact);
|
||||
const summary = sampleSummary(spec.summary);
|
||||
writeCheckArtifact(spec, summary, artifactPath);
|
||||
evidence.checks.push({
|
||||
id: spec.id,
|
||||
status: 'pass',
|
||||
command: `npm run ${spec.commandIncludes}`,
|
||||
completedAt: isoNow(),
|
||||
artifact,
|
||||
artifactSha256: crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
evidence.attestations.push(...weappAttestations.map(spec => ({
|
||||
id: spec.id,
|
||||
status: 'approved',
|
||||
approver: 'test-owner',
|
||||
approvedAt: isoNow(),
|
||||
notes: spec.label,
|
||||
})));
|
||||
return evidence;
|
||||
});
|
||||
assert.equal(completeWeappEvidence.status, 0, `complete WeApp evidence should pass: ${completeWeappEvidence.stdout}`);
|
||||
|
||||
const missingArtifact = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'auth.remote-smoke');
|
||||
@@ -146,6 +789,7 @@ const wrongSmsPnvsDiagnostics = runGate(tempDir => {
|
||||
const item = evidence.checks.find(check => check.id === 'auth.sms-pnvs-diagnostics');
|
||||
item.summary.env.providerMatchesPnvs = false;
|
||||
item.summary.provider.provider = 'aliyun';
|
||||
rewriteArtifact(tempDir, item, item.summary);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(wrongSmsPnvsDiagnostics.status, 0, 'non-PNVS diagnostics should fail launch gate');
|
||||
@@ -158,6 +802,7 @@ const wrongSmsProviderSmoke = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'auth.sms-pnvs-remote-smoke');
|
||||
item.summary.provider = 'aliyun';
|
||||
rewriteArtifact(tempDir, item, item.summary);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(wrongSmsProviderSmoke.status, 0, 'non-PNVS SMS smoke should fail launch gate');
|
||||
@@ -170,6 +815,7 @@ const wrongMigrationProfile = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'migration.pb-production-dry-run');
|
||||
item.summary.migrationProfile = 'development';
|
||||
rewriteArtifact(tempDir, item, pocketBaseDryRunPayload(item.summary));
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(wrongMigrationProfile.status, 0, 'development dry-run evidence should fail launch gate');
|
||||
@@ -224,6 +870,54 @@ assert.ok(
|
||||
'slow mixed benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const capacityCleanupMismatch = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'performance.tenant-students-100k');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
|
||||
payload.cleanup.remaining.platformUsers = 1;
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(capacityCleanupMismatch.status, 0, 'capacity artifact with managed rows after cleanup should fail');
|
||||
assert.ok(
|
||||
capacityCleanupMismatch.payload.checks?.some(item => item.id === 'check.performance.tenant-students-100k.artifact_summary' && item.status === 'blocker'),
|
||||
'capacity cleanup mismatch should be reported from the artifact',
|
||||
);
|
||||
|
||||
const tenantForeignKeyArtifactMismatch = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'db.tenant-foreign-key-audit');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
|
||||
payload.data.invalidRelations = 1;
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(tenantForeignKeyArtifactMismatch.status, 0, 'tenant foreign key artifact with invalid data relations should fail');
|
||||
assert.ok(
|
||||
tenantForeignKeyArtifactMismatch.payload.checks?.some(item => item.id === 'check.db.tenant-foreign-key-audit.artifact_summary' && item.status === 'blocker'),
|
||||
'tenant foreign key mismatch must be reported from the raw artifact',
|
||||
);
|
||||
|
||||
const corsArtifactMismatch = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'api.dynamic-tenant-cors-smoke');
|
||||
const artifactPath = path.join(tempDir, item.artifact);
|
||||
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
|
||||
payload.unknownOriginDenied = false;
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(corsArtifactMismatch.status, 0, 'dynamic CORS artifact mismatch should fail');
|
||||
assert.ok(
|
||||
corsArtifactMismatch.payload.checks?.some(item => item.id === 'check.api.dynamic-tenant-cors-smoke.artifact_summary' && item.status === 'blocker'),
|
||||
'dynamic CORS mismatch should be reported from the artifact',
|
||||
);
|
||||
|
||||
const missingLaunchPersonaSmoke = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'api.launch-persona-smoke');
|
||||
@@ -239,6 +933,7 @@ const launchPersonaWithoutSvip = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke');
|
||||
item.summary.student.result.entitlement.isSvip = false;
|
||||
rewriteArtifact(tempDir, item, item.summary);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(launchPersonaWithoutSvip.status, 0, 'launch persona smoke without SVIP should fail launch gate');
|
||||
@@ -251,6 +946,7 @@ const launchPersonaLegacyAuth = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke');
|
||||
item.summary.authMode = 'legacy';
|
||||
rewriteArtifact(tempDir, item, item.summary);
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(launchPersonaLegacyAuth.status, 0, 'launch persona smoke with legacy auth should fail launch gate');
|
||||
@@ -296,6 +992,7 @@ const oldTaroInteractionCoverage = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'taro.h5-interaction-smoke');
|
||||
item.summary.pass = 26;
|
||||
rewriteArtifact(tempDir, item, jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary));
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(oldTaroInteractionCoverage.status, 0, 'old 26-check H5 interaction evidence should fail launch gate');
|
||||
@@ -308,6 +1005,7 @@ const missingAdminWriteCoverage = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'taro.h5-interaction-smoke');
|
||||
item.summary.mockApi.keyRequests.platformAdminWrites = 0;
|
||||
rewriteArtifact(tempDir, item, jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary));
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingAdminWriteCoverage.status, 0, 'H5 interaction evidence without platform admin writes should fail launch gate');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,29 @@ function runReadiness(envContent, options = {}) {
|
||||
fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.providerRows }, null, 2), 'utf8');
|
||||
args.push('--provider-config-fixture', fixtureFile);
|
||||
}
|
||||
if (options.tenantRows) {
|
||||
const fixtureFile = path.join(tempDir, 'tenant-fixture.json');
|
||||
fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.tenantRows }, null, 2), 'utf8');
|
||||
args.push('--tenant-config-fixture', fixtureFile);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(options, 'environmentSafetyRow')) {
|
||||
const fixtureFile = path.join(tempDir, 'environment-safety-fixture.json');
|
||||
fs.writeFileSync(
|
||||
fixtureFile,
|
||||
JSON.stringify({ row: options.environmentSafetyRow }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
args.push('--environment-safety-fixture', fixtureFile);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(options, 'migrationHistoryRow')) {
|
||||
const fixtureFile = path.join(tempDir, 'migration-history-fixture.json');
|
||||
fs.writeFileSync(
|
||||
fixtureFile,
|
||||
JSON.stringify({ row: options.migrationHistoryRow }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
args.push('--migration-history-fixture', fixtureFile);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
@@ -36,6 +59,39 @@ function runReadiness(envContent, options = {}) {
|
||||
return { ...result, payload };
|
||||
}
|
||||
|
||||
function productionEnv(overrides = '') {
|
||||
return `
|
||||
NODE_ENV=production
|
||||
${overrides}
|
||||
DATABASE_URL=postgresql://tiku_api:prod_password@db.prod.internal:5432/tiku
|
||||
DB_EXPECTED_RUNTIME_ROLE=tiku_api
|
||||
CORS_ORIGIN=https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
|
||||
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
|
||||
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
|
||||
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=s3cure-platform-admin-key-2026-06-29-mnopqr
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=tiku-assets
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
`;
|
||||
}
|
||||
|
||||
const unsafe = runReadiness(`
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
@@ -57,6 +113,10 @@ const unsupportedSmsProvider = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-production
|
||||
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
|
||||
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
|
||||
@@ -88,6 +148,10 @@ const traditionalAliyunSmsProvider = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun
|
||||
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
|
||||
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
|
||||
@@ -123,6 +187,10 @@ const safe = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -150,10 +218,21 @@ WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
|
||||
WORKER_CRM_BATCH_SIZE=20
|
||||
WORKER_CRM_POLL_INTERVAL_MS=10000
|
||||
WORKER_COMMERCE_BATCH_SIZE=20
|
||||
WORKER_COMMERCE_POLL_INTERVAL_MS=30000
|
||||
WORKER_PROVIDER_BILL_POLL_INTERVAL_MS=60000
|
||||
WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS=30000
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS=30000
|
||||
WORKER_ASSET_BATCH_SIZE=50
|
||||
WORKER_ASSET_POLL_INTERVAL_MS=30000
|
||||
WORKER_IMPORT_BATCH_SIZE=5
|
||||
WORKER_IMPORT_POLL_INTERVAL_MS=10000
|
||||
WORKER_IMPORT_LEASE_SECONDS=120
|
||||
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
|
||||
WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5
|
||||
WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS=60000
|
||||
WORKER_EXPORT_POLL_INTERVAL_MS=10000
|
||||
`);
|
||||
|
||||
assert.equal(safe.status, 0, `safe readiness should pass without blockers: ${safe.stdout} ${safe.stderr}`);
|
||||
@@ -163,10 +242,175 @@ assert.ok(
|
||||
'env-only readiness should explicitly warn that DB checks are skipped',
|
||||
);
|
||||
|
||||
const migrationFiles = fs.readdirSync(path.join(repoRoot, 'supabase', 'migrations'))
|
||||
.filter(file => file.endsWith('.sql'))
|
||||
.sort();
|
||||
const latestMigrationVersion = /^(\d+)_/.exec(migrationFiles.at(-1) || '')?.[1];
|
||||
assert.ok(latestMigrationVersion, 'repository must have a latest numeric Supabase migration');
|
||||
|
||||
const currentMigrationHistory = runReadiness(productionEnv(), {
|
||||
migrationHistoryRow: {
|
||||
latestVersion: latestMigrationVersion,
|
||||
appliedCount: migrationFiles.length,
|
||||
distinctVersionCount: migrationFiles.length,
|
||||
expectedVersionApplied: true,
|
||||
},
|
||||
});
|
||||
assert.equal(currentMigrationHistory.status, 0, 'current migration history fixture should pass');
|
||||
assert.ok(
|
||||
currentMigrationHistory.payload.checks?.some(item => (
|
||||
item.id === 'db.migrations.current' && item.status === 'pass'
|
||||
)),
|
||||
'readiness should pass migration history that includes the repository latest version',
|
||||
);
|
||||
|
||||
const staleMigrationHistory = runReadiness(productionEnv(), {
|
||||
migrationHistoryRow: {
|
||||
latestVersion: '202607120018',
|
||||
appliedCount: migrationFiles.length - 1,
|
||||
distinctVersionCount: migrationFiles.length - 1,
|
||||
expectedVersionApplied: false,
|
||||
},
|
||||
});
|
||||
assert.notEqual(staleMigrationHistory.status, 0, 'stale migration history must fail readiness');
|
||||
assert.ok(
|
||||
staleMigrationHistory.payload.checks?.some(item => (
|
||||
item.id === 'db.migrations.current' && item.status === 'blocker'
|
||||
)),
|
||||
'readiness should block a database missing the repository latest migration',
|
||||
);
|
||||
|
||||
const inconsistentMigrationHistory = runReadiness(productionEnv(), {
|
||||
migrationHistoryRow: {
|
||||
latestVersion: latestMigrationVersion,
|
||||
appliedCount: migrationFiles.length + 1,
|
||||
distinctVersionCount: migrationFiles.length,
|
||||
expectedVersionApplied: true,
|
||||
},
|
||||
});
|
||||
assert.notEqual(inconsistentMigrationHistory.status, 0, 'duplicate migration history must fail readiness');
|
||||
|
||||
const truncatedMigrationHistory = runReadiness(productionEnv(), {
|
||||
migrationHistoryRow: {
|
||||
latestVersion: latestMigrationVersion,
|
||||
appliedCount: migrationFiles.length - 1,
|
||||
distinctVersionCount: migrationFiles.length - 1,
|
||||
expectedVersionApplied: true,
|
||||
},
|
||||
});
|
||||
assert.notEqual(
|
||||
truncatedMigrationHistory.status,
|
||||
0,
|
||||
'migration history shorter than the repository migration set must fail readiness',
|
||||
);
|
||||
|
||||
const dynamicTenantCorsDisabled = runReadiness(productionEnv('CORS_TENANT_DOMAINS_ENABLED=false'));
|
||||
assert.notEqual(dynamicTenantCorsDisabled.status, 0, 'disabled tenant-domain CORS must fail production readiness');
|
||||
assert.ok(
|
||||
dynamicTenantCorsDisabled.payload.checks?.some(item => (
|
||||
item.id === 'env.cors_tenant_domains_enabled' && item.status === 'blocker'
|
||||
)),
|
||||
'readiness must require dynamic tenant-domain CORS in production',
|
||||
);
|
||||
|
||||
for (const [override, checkId] of [
|
||||
['WORKER_IMPORT_LEASE_SECONDS=9', 'env.worker_import_lease_seconds'],
|
||||
[
|
||||
'WORKER_IMPORT_LEASE_SECONDS=60\nWORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000',
|
||||
'env.worker_import_heartbeat_interval_ms',
|
||||
],
|
||||
]) {
|
||||
const result = runReadiness(productionEnv(override));
|
||||
assert.notEqual(result.status, 0, `${checkId} should fail production readiness`);
|
||||
assert.ok(
|
||||
result.payload.checks?.some(item => item.id === checkId && item.status === 'blocker'),
|
||||
`${checkId} should be reported as a blocker`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [key, unsafeValue] of [
|
||||
['CORS_TENANT_DOMAIN_CACHE_TTL_MS', '999'],
|
||||
['CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', '300001'],
|
||||
['CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', '99'],
|
||||
]) {
|
||||
const result = runReadiness(productionEnv(`${key}=${unsafeValue}`));
|
||||
assert.notEqual(result.status, 0, `${key} outside the production range must fail readiness`);
|
||||
assert.ok(
|
||||
result.payload.checks?.some(item => (
|
||||
item.id === `env.${key.toLowerCase()}` && item.status === 'blocker'
|
||||
)),
|
||||
`readiness must block unsafe ${key}`,
|
||||
);
|
||||
}
|
||||
|
||||
const missingExpectedRole = runReadiness(productionEnv('DB_EXPECTED_RUNTIME_ROLE='));
|
||||
assert.equal(
|
||||
missingExpectedRole.status,
|
||||
0,
|
||||
'env-only readiness should defer the authoritative runtime-role identity check to --check-db',
|
||||
);
|
||||
assert.ok(
|
||||
missingExpectedRole.payload.checks?.some(item => (
|
||||
item.id === 'env.db_expected_runtime_role' && item.status === 'warn'
|
||||
)),
|
||||
'env-only readiness should warn when DB_EXPECTED_RUNTIME_ROLE is absent',
|
||||
);
|
||||
|
||||
const mismatchedExpectedRole = runReadiness(productionEnv('DB_EXPECTED_RUNTIME_ROLE=tiku_worker'));
|
||||
assert.notEqual(mismatchedExpectedRole.status, 0, 'DATABASE_URL role mismatch must fail readiness');
|
||||
assert.ok(
|
||||
mismatchedExpectedRole.payload.checks?.some(item => (
|
||||
item.id === 'env.database_runtime_role' && item.status === 'blocker'
|
||||
)),
|
||||
'readiness must block a DATABASE_URL username that differs from DB_EXPECTED_RUNTIME_ROLE',
|
||||
);
|
||||
|
||||
const invalidWorkerPollInterval = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
|
||||
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=${strongSecretC}
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=tiku-assets
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
WORKER_CRM_POLL_INTERVAL_MS=0
|
||||
`);
|
||||
|
||||
assert.notEqual(invalidWorkerPollInterval.status, 0, 'invalid production worker poll interval should fail readiness');
|
||||
assert.ok(
|
||||
invalidWorkerPollInterval.payload.checks?.some(item => (
|
||||
item.id === 'env.worker_crm_poll_interval_ms' && item.status === 'blocker'
|
||||
)),
|
||||
'readiness should block invalid worker poll intervals',
|
||||
);
|
||||
|
||||
const safeAliyunPnvs = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -200,6 +444,10 @@ const safeAliyunPnvsUnderscoreAlias = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun_pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -247,6 +495,10 @@ const pnvsTemplateParamWarning = runReadiness(
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -299,6 +551,10 @@ const mismatchedSmsProviderFixture = runReadiness(
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -350,6 +606,10 @@ const legacyTencentSmsProviderFixture = runReadiness(
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -408,6 +668,10 @@ const unsafeProviderFixture = runReadiness(
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -485,6 +749,10 @@ const missingJwksIssuer = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -517,6 +785,10 @@ const unsafePlatformAuditNotificationLocalhost = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -549,6 +821,10 @@ const unsafePlatformDunningNotificationLocalhost = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
@@ -578,4 +854,157 @@ assert.ok(
|
||||
'readiness should block platform dunning notification localhost mode in production',
|
||||
);
|
||||
|
||||
const tenantConfigBaseEnv = `
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
CORS_TENANT_DOMAINS_ENABLED=true
|
||||
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
|
||||
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
|
||||
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
|
||||
AUTH_SMS_PROVIDER=aliyun-pnvs
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
|
||||
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=${strongSecretC}
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=tiku-assets
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
`;
|
||||
|
||||
const unsafeTenantPublicUrl = runReadiness(tenantConfigBaseEnv, {
|
||||
tenantRows: [{
|
||||
tenantId: 'tenant-local-url',
|
||||
slug: 'local-url',
|
||||
name: 'Local URL tenant',
|
||||
publicConfig: { frontend: { appUrl: 'http://127.0.0.1:5173' } },
|
||||
publishedTheme: { primaryColor: '#2563eb' },
|
||||
themeStatus: 'published',
|
||||
publishedAt: '2026-07-11T00:00:00.000Z',
|
||||
}],
|
||||
});
|
||||
|
||||
assert.notEqual(unsafeTenantPublicUrl.status, 0, 'active tenant localhost public URLs should fail readiness');
|
||||
assert.ok(
|
||||
unsafeTenantPublicUrl.payload.checks?.some(item => item.id === 'db.tenant_public_urls' && item.status === 'blocker'),
|
||||
'readiness should block active tenant public URLs that are not production HTTPS',
|
||||
);
|
||||
|
||||
const tenantWithoutPublishedTheme = runReadiness(tenantConfigBaseEnv, {
|
||||
tenantRows: [{
|
||||
tenantId: 'tenant-default-theme',
|
||||
slug: 'default-theme',
|
||||
name: 'Default theme tenant',
|
||||
publicConfig: { appUrl: 'https://student.gongxue100.com' },
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(tenantWithoutPublishedTheme.status, 0, 'missing tenant theme should use platform defaults without blocking readiness');
|
||||
assert.ok(
|
||||
tenantWithoutPublishedTheme.payload.checks?.some(item => item.id === 'db.tenant_theme_published' && item.status === 'warn'),
|
||||
'readiness should warn when an active tenant has no published or branding fallback theme',
|
||||
);
|
||||
|
||||
const tenantWithBrandingFallback = runReadiness(tenantConfigBaseEnv, {
|
||||
tenantRows: [{
|
||||
tenantId: 'tenant-branding-theme',
|
||||
slug: 'branding-theme',
|
||||
name: 'Branding theme tenant',
|
||||
publicConfig: { appUrl: 'https://student.gongxue100.com' },
|
||||
brandingTheme: { primaryColor: '#0f766e' },
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(tenantWithBrandingFallback.status, 0, 'branding theme fallback should pass readiness');
|
||||
assert.ok(
|
||||
tenantWithBrandingFallback.payload.checks?.some(item => item.id === 'db.tenant_theme_published' && item.status === 'pass'),
|
||||
'readiness should accept a non-empty tenant branding fallback theme',
|
||||
);
|
||||
|
||||
for (const environmentSafetyRow of [
|
||||
null,
|
||||
{ environment: 'production', allowDestructiveTests: false },
|
||||
{ environment: 'staging', allow_destructive_tests: false },
|
||||
]) {
|
||||
const result = runReadiness(tenantConfigBaseEnv, { environmentSafetyRow });
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`production readiness should accept a missing or disabled production/staging marker: ${result.stdout} ${result.stderr}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.payload.checks?.some(item => (
|
||||
item.id === 'db.environment.destructive_tests_disabled' && item.status === 'pass'
|
||||
)),
|
||||
'production readiness should record the safe destructive-test marker state',
|
||||
);
|
||||
}
|
||||
|
||||
for (const environmentSafetyRow of [
|
||||
{ environment: 'local', allowDestructiveTests: false },
|
||||
{ environment: 'test', allowDestructiveTests: false },
|
||||
{ environment: 'ci', allowDestructiveTests: false },
|
||||
{ environment: 'production', allowDestructiveTests: true },
|
||||
{ environment: 'staging', allowDestructiveTests: true },
|
||||
{ environment: 'unknown', allowDestructiveTests: false },
|
||||
{ environment: 'production', allowDestructiveTests: 'false' },
|
||||
]) {
|
||||
const result = runReadiness(tenantConfigBaseEnv, { environmentSafetyRow });
|
||||
assert.notEqual(
|
||||
result.status,
|
||||
0,
|
||||
`production readiness must reject unsafe destructive-test marker ${JSON.stringify(environmentSafetyRow)}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.payload.checks?.some(item => (
|
||||
item.id === 'db.environment.destructive_tests_disabled' && item.status === 'blocker'
|
||||
)),
|
||||
'production readiness should block unsafe destructive-test marker state',
|
||||
);
|
||||
}
|
||||
|
||||
const readinessSource = fs.readFileSync(scriptPath, 'utf8');
|
||||
assert.match(
|
||||
readinessSource,
|
||||
/to_regclass\('app_private\.environment_safety'\)/,
|
||||
'database readiness must verify that the environment safety migration exists',
|
||||
);
|
||||
assert.match(
|
||||
readinessSource,
|
||||
/from app_private\.environment_safety[\s\S]*where id = true/i,
|
||||
'database readiness must read the authoritative destructive-test marker',
|
||||
);
|
||||
for (const gateId of [
|
||||
'db.runtime_role.identity',
|
||||
'db.runtime_role.attributes',
|
||||
'db.runtime_role.schema_acl',
|
||||
'db.runtime_role.table_acl',
|
||||
'db.runtime_role.function_acl',
|
||||
'db.runtime_role.ownership',
|
||||
'db.runtime_role.ddl_denied',
|
||||
]) {
|
||||
assert.ok(readinessSource.includes(gateId), `database readiness must include ${gateId}`);
|
||||
}
|
||||
assert.match(
|
||||
readinessSource,
|
||||
/current_user[\s\S]*session_user[\s\S]*DB_EXPECTED_RUNTIME_ROLE/i,
|
||||
'database readiness must verify current_user and session_user against DB_EXPECTED_RUNTIME_ROLE',
|
||||
);
|
||||
assert.match(
|
||||
readinessSource,
|
||||
/has_database_privilege[\s\S]*has_schema_privilege[\s\S]*db\.runtime_role\.ddl_denied/i,
|
||||
'database readiness must verify effective persistent DDL privileges without writing to production',
|
||||
);
|
||||
|
||||
console.log('[PASS] production readiness check script');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
46
scripts/release-artifact-hash.js
Normal file
46
scripts/release-artifact-hash.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(value) {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function walkFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkFiles(entryPath));
|
||||
else files.push(entryPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function hashArtifactDirectory(dir) {
|
||||
const files = walkFiles(dir)
|
||||
.map(filePath => ({
|
||||
filePath,
|
||||
relativePath: normalizeSlashes(path.relative(dir, filePath)),
|
||||
}))
|
||||
.sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'en'));
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
let totalBytes = 0;
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file.filePath);
|
||||
totalBytes += content.length;
|
||||
hash.update(file.relativePath, 'utf8');
|
||||
hash.update('\0');
|
||||
hash.update(String(content.length), 'utf8');
|
||||
hash.update('\0');
|
||||
hash.update(content);
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
return {
|
||||
sha256: hash.digest('hex'),
|
||||
files: files.length,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
62
scripts/remote-tenant-cors-smoke-test.js
Normal file
62
scripts/remote-tenant-cors-smoke-test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { runRemoteTenantCorsSmoke } from './remote-tenant-cors-smoke.js';
|
||||
|
||||
const origins = {
|
||||
active: 'https://active.gongxue100.test',
|
||||
disabled: 'https://disabled.gongxue100.test',
|
||||
unknown: 'https://unknown.gongxue100.test',
|
||||
};
|
||||
|
||||
function json(res, status, payload, headers = {}) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const origin = req.headers.origin || '';
|
||||
if (req.url === '/health' && req.method === 'GET' && !origin) {
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (req.method === 'OPTIONS' && req.url === '/api/tenant/resolve') {
|
||||
if (origin === origins.active) {
|
||||
res.writeHead(204, {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
||||
vary: 'Origin',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
json(res, 403, { code: 'CORS_ORIGIN_DENIED' });
|
||||
return;
|
||||
}
|
||||
json(res, 404, { code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const summary = await runRemoteTenantCorsSmoke(
|
||||
{
|
||||
apiBaseUrl: `http://127.0.0.1:${address.port}`,
|
||||
activeOrigin: origins.active,
|
||||
disabledOrigin: origins.disabled,
|
||||
unknownOrigin: origins.unknown,
|
||||
timeoutMs: 5_000,
|
||||
},
|
||||
{ quiet: true },
|
||||
);
|
||||
assert.deepEqual(summary, {
|
||||
failed: 0,
|
||||
activeTenantOriginAllowed: true,
|
||||
unknownOriginDenied: true,
|
||||
disabledOriginDenied: true,
|
||||
noOriginHealthAllowed: true,
|
||||
statuses: { active: 204, disabled: 403, unknown: 403, health: 200 },
|
||||
});
|
||||
console.log('[PASS] remote dynamic tenant CORS smoke script');
|
||||
} finally {
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
168
scripts/remote-tenant-cors-smoke.js
Normal file
168
scripts/remote-tenant-cors-smoke.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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 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 normalizeOrigin(value, key) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${key} must be a valid URL origin`);
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||
throw new Error(`${key} must contain only scheme, host and optional port`);
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { json: argv.includes('--json'), quiet: argv.includes('--quiet'), writePath: '' };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--write') {
|
||||
options.writePath = argv[index + 1] || '';
|
||||
index += 1;
|
||||
} else if (arg.startsWith('--write=')) {
|
||||
options.writePath = arg.slice('--write='.length);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildConfig(env = process.env) {
|
||||
const apiBaseUrl = envString(env, 'TENANT_CORS_API_BASE_URL', envString(env, 'API_BASE', ''));
|
||||
const activeOrigin = envString(env, 'TENANT_CORS_ACTIVE_ORIGIN');
|
||||
const disabledOrigin = envString(env, 'TENANT_CORS_DISABLED_ORIGIN');
|
||||
const unknownOrigin = envString(env, 'TENANT_CORS_UNKNOWN_ORIGIN');
|
||||
const missing = [];
|
||||
if (!apiBaseUrl) missing.push('TENANT_CORS_API_BASE_URL');
|
||||
if (!activeOrigin) missing.push('TENANT_CORS_ACTIVE_ORIGIN');
|
||||
if (!disabledOrigin) missing.push('TENANT_CORS_DISABLED_ORIGIN');
|
||||
if (!unknownOrigin) missing.push('TENANT_CORS_UNKNOWN_ORIGIN');
|
||||
if (missing.length > 0) throw new Error(`Missing required remote tenant CORS smoke env: ${missing.join(', ')}`);
|
||||
|
||||
return {
|
||||
apiBaseUrl: normalizeBaseUrl(apiBaseUrl),
|
||||
activeOrigin: normalizeOrigin(activeOrigin, 'TENANT_CORS_ACTIVE_ORIGIN'),
|
||||
disabledOrigin: normalizeOrigin(disabledOrigin, 'TENANT_CORS_DISABLED_ORIGIN'),
|
||||
unknownOrigin: normalizeOrigin(unknownOrigin, 'TENANT_CORS_UNKNOWN_ORIGIN'),
|
||||
timeoutMs: envNumber(env, 'TENANT_CORS_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
|
||||
};
|
||||
}
|
||||
|
||||
async function request(config, { origin = '', method = 'OPTIONS', pathName = '/api/tenant/resolve' } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetch(new URL(pathName, config.apiBaseUrl), {
|
||||
method,
|
||||
headers: origin
|
||||
? {
|
||||
origin,
|
||||
'access-control-request-method': 'GET',
|
||||
'access-control-request-headers': 'content-type,x-tenant-code',
|
||||
}
|
||||
: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
if (text.trim()) {
|
||||
try { payload = JSON.parse(text); } catch { payload = { raw: text.slice(0, 300) }; }
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
allowOrigin: response.headers.get('access-control-allow-origin') || '',
|
||||
vary: response.headers.get('vary') || '',
|
||||
payload,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message, detail = {}) {
|
||||
if (condition) return;
|
||||
const error = new Error(message);
|
||||
error.detail = detail;
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function runRemoteTenantCorsSmoke(inputConfig, options = {}) {
|
||||
const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env);
|
||||
const active = await request(config, { origin: config.activeOrigin });
|
||||
assert(active.status === 204, 'active tenant Origin preflight must return HTTP 204', active);
|
||||
assert(active.allowOrigin === config.activeOrigin, 'active tenant Origin must be echoed in access-control-allow-origin', active);
|
||||
assert(/(?:^|,)\s*origin\s*(?:,|$)/i.test(active.vary), 'active tenant Origin response must vary by Origin', active);
|
||||
|
||||
const disabled = await request(config, { origin: config.disabledOrigin });
|
||||
assert(disabled.status === 403, 'disabled tenant Origin preflight must return HTTP 403', disabled);
|
||||
assert(!disabled.allowOrigin, 'disabled tenant Origin must not receive access-control-allow-origin', disabled);
|
||||
assert(disabled.payload?.code === 'CORS_ORIGIN_DENIED', 'disabled tenant Origin must fail with CORS_ORIGIN_DENIED', disabled);
|
||||
|
||||
const unknown = await request(config, { origin: config.unknownOrigin });
|
||||
assert(unknown.status === 403, 'unknown tenant Origin preflight must return HTTP 403', unknown);
|
||||
assert(!unknown.allowOrigin, 'unknown tenant Origin must not receive access-control-allow-origin', unknown);
|
||||
assert(unknown.payload?.code === 'CORS_ORIGIN_DENIED', 'unknown tenant Origin must fail with CORS_ORIGIN_DENIED', unknown);
|
||||
|
||||
const health = await request(config, { method: 'GET', pathName: '/health' });
|
||||
assert(health.status === 200, 'health request without Origin must remain available', health);
|
||||
assert(health.payload?.ok === true, 'health request without Origin must return ok=true', health);
|
||||
|
||||
const summary = {
|
||||
failed: 0,
|
||||
activeTenantOriginAllowed: true,
|
||||
unknownOriginDenied: true,
|
||||
disabledOriginDenied: true,
|
||||
noOriginHealthAllowed: true,
|
||||
statuses: {
|
||||
active: active.status,
|
||||
disabled: disabled.status,
|
||||
unknown: unknown.status,
|
||||
health: health.status,
|
||||
},
|
||||
};
|
||||
if (!options.quiet) console.log('[PASS] remote dynamic tenant CORS smoke');
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
try {
|
||||
const summary = await runRemoteTenantCorsSmoke(buildConfig(), { quiet: options.quiet || options.json });
|
||||
if (options.writePath) {
|
||||
const resolved = path.resolve(process.cwd(), options.writePath);
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
fs.writeFileSync(resolved, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
if (options.json) console.log(JSON.stringify(summary, null, 2));
|
||||
} catch (error) {
|
||||
const failure = { failed: 1, error: error.message, detail: error.detail || undefined };
|
||||
if (options.json) console.log(JSON.stringify(failure, null, 2));
|
||||
else {
|
||||
console.error(error.message);
|
||||
if (error.detail) console.error(JSON.stringify(error.detail, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();
|
||||
|
||||
export { buildConfig, runRemoteTenantCorsSmoke };
|
||||
@@ -41,6 +41,7 @@ try {
|
||||
[
|
||||
'DATABASE_URL=postgresql://postgres:real-password@db.example.com:5432/postgres',
|
||||
'SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake-service-role-token-that-should-not-ship',
|
||||
'GITEA_TOKEN=0123456789abcdef0123456789abcdef01234567',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
@@ -52,6 +53,7 @@ try {
|
||||
assert.ok(payload.findings.some(item => item.id === 'frontend-legacy-user-header'), 'x-user-id should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'postgres-url'), 'database URL should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'supabase-service-role'), 'service role key should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'git-access-token'), 'Git access token should be detected');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ const ruleAllowlistedFiles = {
|
||||
'frontend-legacy-user-header': new Set([
|
||||
'scripts/repo-security-scan-test.js',
|
||||
]),
|
||||
'git-access-token': new Set([
|
||||
'scripts/repo-security-scan-test.js',
|
||||
]),
|
||||
};
|
||||
|
||||
const rules = [
|
||||
@@ -113,6 +116,13 @@ const rules = [
|
||||
pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/,
|
||||
message: 'Provider secret keys must not be committed.',
|
||||
},
|
||||
{
|
||||
id: 'git-access-token',
|
||||
severity: 'critical',
|
||||
pattern: /\b(?:GITEA_TOKEN|GIT_TOKEN)\s*[:=]\s*["']?([A-Za-z0-9_-]{32,})/i,
|
||||
message: 'Git access tokens must stay in server-side secret storage.',
|
||||
validate: (match) => !/^(?:replace|example|your|rotated|placeholder)/i.test(String(match[1] || '')),
|
||||
},
|
||||
{
|
||||
id: 'cloud-access-key',
|
||||
severity: 'critical',
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import pg from 'pg';
|
||||
import {
|
||||
assertDestructiveTestDatabase,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
|
||||
|
||||
const ids = {
|
||||
mainTenant: '00000000-0000-0000-0000-000000000001',
|
||||
partnerTenant: '00000000-0000-0000-0000-000000000901',
|
||||
mainUser: '00000000-0000-0000-0000-000000000101',
|
||||
normalAuthUser: '00000000-0000-0000-0000-00000000a101',
|
||||
platformAdminAuthUser: '00000000-0000-0000-0000-00000000a999',
|
||||
partnerAdminUser: '00000000-0000-0000-0000-000000000907',
|
||||
};
|
||||
|
||||
@@ -15,74 +21,62 @@ const readChecks = [
|
||||
{
|
||||
name: 'tenant_branding',
|
||||
sql: 'select tenant_id::text as tenant_id, brand_name as label from public.tenant_branding order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
|
||||
},
|
||||
{
|
||||
name: 'tenant_settings',
|
||||
sql: 'select tenant_id::text as tenant_id, public_config::text as label from public.tenant_settings order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
|
||||
},
|
||||
{
|
||||
name: 'tenant_domains',
|
||||
sql: 'select tenant_id::text as tenant_id, host as label from public.tenant_domains order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
|
||||
},
|
||||
{
|
||||
name: 'tenant_memberships',
|
||||
sql: 'select tenant_id::text as tenant_id, role as label from public.tenant_memberships order by tenant_id, role',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
|
||||
},
|
||||
{
|
||||
name: 'regions',
|
||||
sql: 'select tenant_id::text as tenant_id, name as label from public.regions order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.mainTenant],
|
||||
},
|
||||
{
|
||||
name: 'questions',
|
||||
sql: 'select tenant_id::text as tenant_id, legacy_id as label from public.questions order by tenant_id, id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
requiredTenantIds: [ids.mainTenant],
|
||||
},
|
||||
{
|
||||
name: 'student_profiles',
|
||||
sql: 'select tenant_id::text as tenant_id, user_id::text as label from public.student_profiles order by tenant_id, user_id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
requiredTenantIds: [ids.mainTenant],
|
||||
},
|
||||
{
|
||||
name: 'orders',
|
||||
sql: 'select tenant_id::text as tenant_id, order_no as label from public.orders order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
requiredTenantIds: [ids.mainTenant],
|
||||
},
|
||||
{
|
||||
name: 'content_assets',
|
||||
sql: 'select tenant_id::text as tenant_id, asset_key as label from public.content_assets order by tenant_id',
|
||||
expectMain: true,
|
||||
expectPartner: false,
|
||||
requiredTenantIds: [ids.mainTenant],
|
||||
},
|
||||
{
|
||||
name: 'tenant_subscriptions',
|
||||
sql: 'select tenant_id::text as tenant_id, plan_code as label from public.tenant_subscriptions order by tenant_id',
|
||||
expectMain: false,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.partnerTenant],
|
||||
},
|
||||
{
|
||||
name: 'tenant_invoices',
|
||||
sql: 'select tenant_id::text as tenant_id, invoice_no as label from public.tenant_invoices order by tenant_id',
|
||||
expectMain: false,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.partnerTenant],
|
||||
},
|
||||
{
|
||||
name: 'tenant_usage_records',
|
||||
sql: 'select tenant_id::text as tenant_id, metric_key as label from public.tenant_usage_records order by tenant_id, metric_key',
|
||||
expectMain: false,
|
||||
expectPartner: true,
|
||||
requiredTenantIds: [ids.partnerTenant],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -159,14 +153,27 @@ function onlyTenantRows(rows, tenantId) {
|
||||
return rows.every(row => row.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
function hasTenantRows(rows, tenantId) {
|
||||
return rows.some(row => row.tenant_id === tenantId);
|
||||
function tenantRows(rows, tenantId) {
|
||||
return rows.filter(row => row.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
async function withRlsContext(client, { dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' }, action) {
|
||||
function canonicalRows(rows) {
|
||||
return rows.map(row => JSON.stringify(row)).sort();
|
||||
}
|
||||
|
||||
function sameRows(actual, expected) {
|
||||
return JSON.stringify(canonicalRows(actual)) === JSON.stringify(canonicalRows(expected));
|
||||
}
|
||||
|
||||
async function withRlsContext(
|
||||
client,
|
||||
{ dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' },
|
||||
action,
|
||||
extraGrantStatements = [],
|
||||
) {
|
||||
await client.query('begin');
|
||||
try {
|
||||
for (const statement of probeGrantStatements) await client.query(statement);
|
||||
for (const statement of [...probeGrantStatements, ...extraGrantStatements]) await client.query(statement);
|
||||
await client.query(`set local role ${dbRole}`);
|
||||
if (tenantId) {
|
||||
await client.query("select set_config('request.jwt.claim.tenant_id', $1, true)", [tenantId]);
|
||||
@@ -193,8 +200,19 @@ async function queryAs(client, context, sql, params = []) {
|
||||
});
|
||||
}
|
||||
|
||||
async function runReadIsolationChecks(client) {
|
||||
async function captureReadBaselines(client) {
|
||||
const baselines = new Map();
|
||||
for (const check of readChecks) {
|
||||
const result = await client.query(check.sql);
|
||||
baselines.set(check.name, result.rows);
|
||||
}
|
||||
return baselines;
|
||||
}
|
||||
|
||||
async function runReadIsolationChecks(client, baselines) {
|
||||
for (const check of readChecks) {
|
||||
const baselineRows = baselines.get(check.name) || [];
|
||||
const expectedMainRows = tenantRows(baselineRows, ids.mainTenant);
|
||||
const mainRows = await queryAs(client, { tenantId: ids.mainTenant }, check.sql);
|
||||
assert(
|
||||
onlyTenantRows(mainRows, ids.mainTenant),
|
||||
@@ -203,12 +221,13 @@ async function runReadIsolationChecks(client) {
|
||||
{ rows: mainRows },
|
||||
);
|
||||
assert(
|
||||
hasTenantRows(mainRows, ids.mainTenant) === check.expectMain,
|
||||
sameRows(mainRows, expectedMainRows),
|
||||
`rls.read.${check.name}.main_expected_seed`,
|
||||
'主租户 seed 数据存在性不符合预期',
|
||||
{ expected: check.expectMain, rows: mainRows },
|
||||
'主租户上下文应返回基线快照中该租户的完整数据子集',
|
||||
{ expectedRows: expectedMainRows, rows: mainRows },
|
||||
);
|
||||
|
||||
const expectedPartnerRows = tenantRows(baselineRows, ids.partnerTenant);
|
||||
const partnerRows = await queryAs(client, { tenantId: ids.partnerTenant }, check.sql);
|
||||
assert(
|
||||
onlyTenantRows(partnerRows, ids.partnerTenant),
|
||||
@@ -217,10 +236,10 @@ async function runReadIsolationChecks(client) {
|
||||
{ rows: partnerRows },
|
||||
);
|
||||
assert(
|
||||
hasTenantRows(partnerRows, ids.partnerTenant) === check.expectPartner,
|
||||
sameRows(partnerRows, expectedPartnerRows),
|
||||
`rls.read.${check.name}.partner_expected_seed`,
|
||||
'伙伴租户 seed 数据存在性不符合预期',
|
||||
{ expected: check.expectPartner, rows: partnerRows },
|
||||
'伙伴租户上下文应返回基线快照中该租户的完整数据子集',
|
||||
{ expectedRows: expectedPartnerRows, rows: partnerRows },
|
||||
);
|
||||
|
||||
const anonymousRows = await queryAs(client, { dbRole: 'anon', tenantId: '', roleClaim: 'anon' }, check.sql);
|
||||
@@ -233,20 +252,114 @@ async function runReadIsolationChecks(client) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPlatformAdminChecks(client) {
|
||||
async function runPlatformAdminChecks(client, baselines) {
|
||||
for (const check of readChecks) {
|
||||
const rows = await queryAs(client, { tenantId: '', roleClaim: 'platform_admin', sub: ids.mainUser }, check.sql);
|
||||
const hasMain = hasTenantRows(rows, ids.mainTenant);
|
||||
const hasPartner = hasTenantRows(rows, ids.partnerTenant);
|
||||
const rows = await queryAs(
|
||||
client,
|
||||
{ tenantId: '', roleClaim: 'platform_admin', sub: ids.platformAdminAuthUser },
|
||||
check.sql,
|
||||
);
|
||||
const baselineRows = baselines.get(check.name) || [];
|
||||
assert(
|
||||
hasMain === check.expectMain && hasPartner === check.expectPartner,
|
||||
sameRows(rows, baselineRows),
|
||||
`rls.platform_admin.${check.name}.cross_tenant_visibility`,
|
||||
'平台管理员 RLS 旁路应只暴露当前表已有的多租户 seed 数据',
|
||||
{ expectedMain: check.expectMain, expectedPartner: check.expectPartner, rows },
|
||||
'平台管理员 RLS 旁路应返回当前表的完整基线快照',
|
||||
{ expectedRows: baselineRows, rows },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runForgedPlatformAdminChecks(client, baselines) {
|
||||
for (const check of readChecks) {
|
||||
const rows = await queryAs(
|
||||
client,
|
||||
{
|
||||
tenantId: ids.mainTenant,
|
||||
roleClaim: 'platform_admin',
|
||||
sub: ids.normalAuthUser,
|
||||
},
|
||||
check.sql,
|
||||
);
|
||||
|
||||
assert(
|
||||
onlyTenantRows(rows, ids.mainTenant),
|
||||
`rls.forged_platform_admin.${check.name}.no_cross_tenant_leak`,
|
||||
'伪造 platform_admin claim 的普通用户不应获得跨租户可见性',
|
||||
{ rows },
|
||||
);
|
||||
const expectedMainRows = tenantRows(baselines.get(check.name) || [], ids.mainTenant);
|
||||
assert(
|
||||
sameRows(rows, expectedMainRows),
|
||||
`rls.forged_platform_admin.${check.name}.tenant_scope_preserved`,
|
||||
'伪造 platform_admin claim 后仍应按普通租户上下文执行 RLS',
|
||||
{ expectedRows: expectedMainRows, rows },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPlatformPrivilegeEscalationCheck(client) {
|
||||
const result = await withRlsContext(
|
||||
client,
|
||||
{
|
||||
tenantId: ids.mainTenant,
|
||||
roleClaim: 'platform_admin',
|
||||
sub: ids.normalAuthUser,
|
||||
},
|
||||
async () => {
|
||||
const before = await client.query(
|
||||
`
|
||||
select auth_user_id::text, primary_role, status, platform_permissions
|
||||
from public.platform_users
|
||||
where auth_user_id = $1::uuid
|
||||
`,
|
||||
[ids.normalAuthUser],
|
||||
);
|
||||
const update = await client.query(
|
||||
`
|
||||
update public.platform_users
|
||||
set primary_role = 'platform_admin',
|
||||
status = 'disabled',
|
||||
platform_permissions = '{"*":true}'::jsonb
|
||||
where auth_user_id = $1::uuid
|
||||
`,
|
||||
[ids.normalAuthUser],
|
||||
);
|
||||
const after = await client.query(
|
||||
`
|
||||
select auth_user_id::text, primary_role, status, platform_permissions
|
||||
from public.platform_users
|
||||
where auth_user_id = $1::uuid
|
||||
`,
|
||||
[ids.normalAuthUser],
|
||||
);
|
||||
return { before: before.rows, updateRowCount: update.rowCount, after: after.rows };
|
||||
},
|
||||
['grant select, update on public.platform_users to authenticated'],
|
||||
);
|
||||
|
||||
assert(
|
||||
result.before.length === 1,
|
||||
'rls.platform_users.normal_user_self_read_probe',
|
||||
'权限提升探针需要能读取普通用户自身记录',
|
||||
result,
|
||||
);
|
||||
assert(
|
||||
result.updateRowCount === 0,
|
||||
'rls.platform_users.self_privilege_escalation_blocked',
|
||||
'即使误授 authenticated UPDATE,RLS 也不应允许用户将自身提升为平台超管',
|
||||
result,
|
||||
);
|
||||
assert(
|
||||
result.after.length === 1
|
||||
&& result.after[0].primary_role === result.before[0].primary_role
|
||||
&& result.after[0].status === result.before[0].status
|
||||
&& JSON.stringify(result.after[0].platform_permissions) === JSON.stringify(result.before[0].platform_permissions),
|
||||
'rls.platform_users.sensitive_fields_unchanged',
|
||||
'普通用户的角色、状态和平台权限字段不应被客户端修改',
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
async function runWriteIsolationChecks(client) {
|
||||
for (const check of writeChecks) {
|
||||
try {
|
||||
@@ -273,7 +386,364 @@ async function runWriteIsolationChecks(client) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySeed(client) {
|
||||
async function runQuestionVersionIntegrityChecks(client) {
|
||||
const constraints = await client.query(
|
||||
`
|
||||
select conname, convalidated
|
||||
from pg_constraint
|
||||
where conrelid in ('public.questions'::regclass, 'public.question_versions'::regclass)
|
||||
and conname = any($1::text[])
|
||||
`,
|
||||
[[
|
||||
'questions_tenant_id_id_key',
|
||||
'question_versions_tenant_question_id_id_key',
|
||||
'question_versions_tenant_question_fkey',
|
||||
'questions_tenant_current_version_fkey',
|
||||
]],
|
||||
);
|
||||
const constraintState = new Map(
|
||||
constraints.rows.map(row => [row.conname, row.convalidated === true]),
|
||||
);
|
||||
for (const name of [
|
||||
'questions_tenant_id_id_key',
|
||||
'question_versions_tenant_question_id_id_key',
|
||||
'question_versions_tenant_question_fkey',
|
||||
'questions_tenant_current_version_fkey',
|
||||
]) {
|
||||
assert(
|
||||
constraintState.get(name) === true,
|
||||
`schema.question_versions.${name}.validated`,
|
||||
'Question version tenant integrity constraints must exist and be validated',
|
||||
{ constraints: constraints.rows },
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('begin');
|
||||
try {
|
||||
const questionA = await client.query(
|
||||
`
|
||||
insert into public.questions (tenant_id, legacy_id, type, status)
|
||||
values ($1, $2, 'choice', 'draft')
|
||||
returning id
|
||||
`,
|
||||
[ids.mainTenant, `question-integrity-a-${Date.now()}`],
|
||||
);
|
||||
const questionB = await client.query(
|
||||
`
|
||||
insert into public.questions (tenant_id, legacy_id, type, status)
|
||||
values ($1, $2, 'choice', 'draft')
|
||||
returning id
|
||||
`,
|
||||
[ids.mainTenant, `question-integrity-b-${Date.now()}`],
|
||||
);
|
||||
const versionA = await client.query(
|
||||
`
|
||||
insert into public.question_versions (tenant_id, question_id, version_no, content)
|
||||
values ($1, $2, 1, 'question integrity probe')
|
||||
returning id
|
||||
`,
|
||||
[ids.mainTenant, questionA.rows[0].id],
|
||||
);
|
||||
|
||||
await client.query('savepoint cross_tenant_version');
|
||||
try {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_versions (tenant_id, question_id, version_no, content)
|
||||
values ($1, $2, 2, 'must be rejected')
|
||||
`,
|
||||
[ids.partnerTenant, questionA.rows[0].id],
|
||||
);
|
||||
fail(
|
||||
'schema.question_versions.cross_tenant_parent_rejected',
|
||||
'A question version must not reference a question from another tenant',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(
|
||||
error.code === '23503',
|
||||
'schema.question_versions.cross_tenant_parent_rejected',
|
||||
'Cross-tenant question version insertion must fail with a foreign key violation',
|
||||
{ code: error.code, message: error.message },
|
||||
);
|
||||
} finally {
|
||||
await client.query('rollback to savepoint cross_tenant_version');
|
||||
}
|
||||
|
||||
await client.query('savepoint cross_question_pointer');
|
||||
try {
|
||||
await client.query(
|
||||
'update public.questions set current_version_id = $1 where id = $2',
|
||||
[versionA.rows[0].id, questionB.rows[0].id],
|
||||
);
|
||||
fail(
|
||||
'schema.questions.cross_question_current_version_rejected',
|
||||
'A question must not point at another question\'s current version',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(
|
||||
error.code === '23503',
|
||||
'schema.questions.cross_question_current_version_rejected',
|
||||
'Cross-question current version assignment must fail with a foreign key violation',
|
||||
{ code: error.code, message: error.message },
|
||||
);
|
||||
} finally {
|
||||
await client.query('rollback to savepoint cross_question_pointer');
|
||||
}
|
||||
|
||||
const validPointer = await client.query(
|
||||
'update public.questions set current_version_id = $1 where id = $2 returning id',
|
||||
[versionA.rows[0].id, questionA.rows[0].id],
|
||||
);
|
||||
assert(
|
||||
validPointer.rowCount === 1,
|
||||
'schema.questions.same_question_current_version_allowed',
|
||||
'A question must be able to reference its own version in the same tenant',
|
||||
);
|
||||
} finally {
|
||||
await client.query('rollback').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function runCoreTenantForeignKeyIntegrityChecks(client) {
|
||||
const constraintNames = [
|
||||
'question_versions_tenant_id_id_key',
|
||||
'practice_sessions_tenant_id_id_key',
|
||||
'orders_tenant_id_id_key',
|
||||
'content_assets_tenant_id_id_key',
|
||||
'practice_sessions_tenant_user_id_key',
|
||||
'answer_records_question_version_requires_question_check',
|
||||
'answer_records_tenant_question_fkey',
|
||||
'answer_records_tenant_question_version_pair_fkey',
|
||||
'answer_records_tenant_user_session_fkey',
|
||||
'favorite_questions_tenant_question_fkey',
|
||||
'wrong_questions_tenant_question_fkey',
|
||||
'payments_tenant_order_fkey',
|
||||
'content_export_jobs_tenant_asset_fkey',
|
||||
];
|
||||
const constraints = await client.query(
|
||||
`
|
||||
select conname, convalidated
|
||||
from pg_constraint
|
||||
where conname = any($1::text[])
|
||||
`,
|
||||
[constraintNames],
|
||||
);
|
||||
const constraintState = new Map(
|
||||
constraints.rows.map(row => [row.conname, row.convalidated === true]),
|
||||
);
|
||||
for (const name of constraintNames) {
|
||||
assert(
|
||||
constraintState.get(name) === true,
|
||||
`schema.core_tenant_foreign_keys.${name}.validated`,
|
||||
'Core tenant foreign key constraints must exist and be validated',
|
||||
{ constraints: constraints.rows },
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('begin');
|
||||
try {
|
||||
const parent = await client.query(
|
||||
`
|
||||
select
|
||||
version.question_id as "questionId",
|
||||
version.id as "versionId",
|
||||
session.id as "sessionId",
|
||||
session.user_id as "sessionUserId",
|
||||
(select o.id from public.orders o where o.tenant_id = $1 order by o.id limit 1) as "orderId",
|
||||
(select a.id from public.content_assets a where a.tenant_id = $1 order by a.id limit 1) as "assetId"
|
||||
from lateral (
|
||||
select v.id, v.question_id
|
||||
from public.question_versions v
|
||||
where v.tenant_id = $1
|
||||
order by v.id
|
||||
limit 1
|
||||
) version
|
||||
cross join lateral (
|
||||
select s.id, s.user_id
|
||||
from public.practice_sessions s
|
||||
where s.tenant_id = $1
|
||||
order by s.id
|
||||
limit 1
|
||||
) session
|
||||
`,
|
||||
[ids.mainTenant],
|
||||
);
|
||||
const references = parent.rows[0];
|
||||
assert(
|
||||
Object.values(references || {}).every(Boolean),
|
||||
'schema.core_tenant_foreign_keys.parent_fixtures_available',
|
||||
'Core tenant foreign key probes require main-tenant parent fixtures',
|
||||
{ references },
|
||||
);
|
||||
|
||||
const probes = [
|
||||
{
|
||||
name: 'answer_question',
|
||||
sql: `insert into public.answer_records (tenant_id, user_id, question_id)
|
||||
values ($1, $2, $3)`,
|
||||
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
|
||||
},
|
||||
{
|
||||
name: 'answer_question_version',
|
||||
sql: `insert into public.answer_records (tenant_id, user_id, question_id, question_version_id)
|
||||
values ($1, $2, $3, $4)`,
|
||||
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId, references.versionId],
|
||||
},
|
||||
{
|
||||
name: 'answer_practice_session',
|
||||
sql: `insert into public.answer_records (tenant_id, user_id, practice_session_id)
|
||||
values ($1, $2, $3)`,
|
||||
params: [ids.partnerTenant, ids.partnerAdminUser, references.sessionId],
|
||||
},
|
||||
{
|
||||
name: 'favorite_question',
|
||||
sql: `insert into public.favorite_questions (tenant_id, user_id, question_id)
|
||||
values ($1, $2, $3)`,
|
||||
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
|
||||
},
|
||||
{
|
||||
name: 'wrong_question',
|
||||
sql: `insert into public.wrong_questions (tenant_id, user_id, question_id)
|
||||
values ($1, $2, $3)`,
|
||||
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
|
||||
},
|
||||
{
|
||||
name: 'payment_order',
|
||||
sql: `insert into public.payments (tenant_id, order_id, provider, amount_cents)
|
||||
values ($1, $2, 'rls-integrity-probe', 1)`,
|
||||
params: [ids.partnerTenant, references.orderId],
|
||||
},
|
||||
{
|
||||
name: 'export_asset',
|
||||
sql: `insert into public.content_export_jobs (
|
||||
tenant_id, export_type, format, scope_type, scope_id, asset_id
|
||||
) values ($1, 'questions', 'json', 'entry', gen_random_uuid(), $2)`,
|
||||
params: [ids.partnerTenant, references.assetId],
|
||||
},
|
||||
];
|
||||
|
||||
for (const probe of probes) {
|
||||
const savepoint = `core_tenant_fk_${probe.name}`;
|
||||
await client.query(`savepoint ${savepoint}`);
|
||||
try {
|
||||
await client.query(probe.sql, probe.params);
|
||||
fail(
|
||||
`schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`,
|
||||
'Cross-tenant parent references must be rejected',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(
|
||||
error.code === '23503',
|
||||
`schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`,
|
||||
'Cross-tenant parent references must fail with a foreign key violation',
|
||||
{ code: error.code, message: error.message },
|
||||
);
|
||||
} finally {
|
||||
await client.query(`rollback to savepoint ${savepoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
const validAnswer = await client.query(
|
||||
`
|
||||
insert into public.answer_records (
|
||||
tenant_id, user_id, question_id, question_version_id, practice_session_id
|
||||
)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
returning id
|
||||
`,
|
||||
[ids.mainTenant, references.sessionUserId, references.questionId, references.versionId, references.sessionId],
|
||||
);
|
||||
assert(
|
||||
validAnswer.rowCount === 1,
|
||||
'schema.core_tenant_foreign_keys.same_tenant_answer_allowed',
|
||||
'A same-tenant answer record must remain writable after composite foreign keys',
|
||||
);
|
||||
|
||||
const differentQuestion = await client.query(
|
||||
`
|
||||
select id
|
||||
from public.questions
|
||||
where tenant_id = $1 and id <> $2
|
||||
order by id
|
||||
limit 1
|
||||
`,
|
||||
[ids.mainTenant, references.questionId],
|
||||
);
|
||||
assert(
|
||||
Boolean(differentQuestion.rows[0]?.id),
|
||||
'schema.core_tenant_foreign_keys.different_question_fixture_available',
|
||||
'Answer version-question integrity probe requires a second question',
|
||||
);
|
||||
await client.query('savepoint answer_version_question_pair');
|
||||
try {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.answer_records (
|
||||
tenant_id, user_id, question_id, question_version_id
|
||||
) values ($1, $2, $3, $4)
|
||||
`,
|
||||
[ids.mainTenant, '00000000-0000-0000-0000-000000000101', differentQuestion.rows[0].id, references.versionId],
|
||||
);
|
||||
fail(
|
||||
'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected',
|
||||
'An answer must not combine a question with another question\'s version',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(
|
||||
error.code === '23503',
|
||||
'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected',
|
||||
'Mismatched answer question/version pairs must fail with a foreign key violation',
|
||||
{ code: error.code, message: error.message },
|
||||
);
|
||||
} finally {
|
||||
await client.query('rollback to savepoint answer_version_question_pair');
|
||||
}
|
||||
|
||||
const alternateUser = await client.query(
|
||||
`
|
||||
select user_id as "userId"
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1 and user_id <> $2
|
||||
order by user_id
|
||||
limit 1
|
||||
`,
|
||||
[ids.mainTenant, references.sessionUserId],
|
||||
);
|
||||
assert(
|
||||
Boolean(alternateUser.rows[0]?.userId),
|
||||
'schema.core_tenant_foreign_keys.alternate_session_user_fixture_available',
|
||||
'Answer session-user integrity probe requires another tenant user',
|
||||
);
|
||||
await client.query('savepoint answer_session_user_pair');
|
||||
try {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.answer_records (
|
||||
tenant_id, user_id, practice_session_id
|
||||
) values ($1, $2, $3)
|
||||
`,
|
||||
[ids.mainTenant, alternateUser.rows[0].userId, references.sessionId],
|
||||
);
|
||||
fail(
|
||||
'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected',
|
||||
'An answer must not reference another user\'s practice session',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(
|
||||
error.code === '23503',
|
||||
'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected',
|
||||
'Mismatched answer user/session pairs must fail with a foreign key violation',
|
||||
{ code: error.code, message: error.message },
|
||||
);
|
||||
} finally {
|
||||
await client.query('rollback to savepoint answer_session_user_pair');
|
||||
}
|
||||
} finally {
|
||||
await client.query('rollback').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySeed(client, baselines) {
|
||||
const result = await client.query(
|
||||
`
|
||||
select tenant_id::text, count(*)::int as count
|
||||
@@ -290,15 +760,38 @@ async function verifySeed(client) {
|
||||
'需要先运行 npm run db:smoke-seed,确保主租户和伙伴租户 seed 都存在',
|
||||
{ rows: result.rows },
|
||||
);
|
||||
for (const check of readChecks) {
|
||||
const rows = baselines.get(check.name) || [];
|
||||
for (const tenantId of check.requiredTenantIds) {
|
||||
const fixtures = tenantRows(rows, tenantId);
|
||||
assert(
|
||||
fixtures.length > 0,
|
||||
`rls.seed.${check.name}.${tenantId === ids.mainTenant ? 'main' : 'partner'}_fixture`,
|
||||
'RLS 深测表必须有明确的非空租户夹具,避免空快照假通过',
|
||||
{ tenantId, rows },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await verifySeed(client);
|
||||
await runReadIsolationChecks(client);
|
||||
await runPlatformAdminChecks(client);
|
||||
await assertDestructiveTestDatabase({
|
||||
client,
|
||||
databaseUrl,
|
||||
confirmation: destructiveTestConfirmation,
|
||||
operation: 'RLS tenant isolation test',
|
||||
});
|
||||
const baselines = await captureReadBaselines(client);
|
||||
await verifySeed(client, baselines);
|
||||
await runReadIsolationChecks(client, baselines);
|
||||
await runPlatformAdminChecks(client, baselines);
|
||||
await runForgedPlatformAdminChecks(client, baselines);
|
||||
await runPlatformPrivilegeEscalationCheck(client);
|
||||
await runWriteIsolationChecks(client);
|
||||
await runQuestionVersionIntegrityChecks(client);
|
||||
await runCoreTenantForeignKeyIntegrityChecks(client);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import pg from 'pg';
|
||||
import {
|
||||
assertDestructiveTestDatabase,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
const confirmation = resolveDestructiveTestConfirmation();
|
||||
|
||||
const ids = {
|
||||
authUser: '00000000-0000-0000-0000-00000000a101',
|
||||
@@ -98,8 +103,16 @@ const pool = new Pool({ connectionString: databaseUrl });
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
let transactionStarted = false;
|
||||
try {
|
||||
await assertDestructiveTestDatabase({
|
||||
client,
|
||||
databaseUrl,
|
||||
confirmation,
|
||||
operation: 'smoke seed',
|
||||
});
|
||||
await client.query('begin');
|
||||
transactionStarted = true;
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -109,6 +122,40 @@ async function main() {
|
||||
`,
|
||||
);
|
||||
|
||||
// A previous integration run may leave a tenant SMS provider active.
|
||||
// Reset the smoke tenant so local tests never call an external SMS API.
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_auth_providers
|
||||
set status = 'disabled',
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and provider in (
|
||||
'aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth',
|
||||
'aliyun', 'aliyun-sms', 'aliyun_sms',
|
||||
'tencent', 'tencent-sms', 'tencent_sms'
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_auth_providers (
|
||||
tenant_id, provider, status, display_name, config_public
|
||||
)
|
||||
values (
|
||||
$1, 'mock', 'testing', '本地模拟短信', '{"channel":"local-dev"}'::jsonb
|
||||
)
|
||||
on conflict (tenant_id, provider)
|
||||
do update set status = 'testing',
|
||||
display_name = excluded.display_name,
|
||||
config_public = excluded.config_public,
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into smoke_seed_transient_questions (id)
|
||||
@@ -2358,9 +2405,12 @@ async function main() {
|
||||
);
|
||||
|
||||
await client.query('commit');
|
||||
transactionStarted = false;
|
||||
console.log(`Smoke seed complete for tenant ${tenantId}`);
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (transactionStarted) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
|
||||
27
scripts/sms-rate-limit-contract-test.js
Normal file
27
scripts/sms-rate-limit-contract-test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const migration = fs.readFileSync('supabase/migrations/202607120012_sms_send_reservation_limits.sql', 'utf8');
|
||||
const routes = fs.readFileSync('apps/api/src/features/auth/routes.ts', 'utf8');
|
||||
const limits = fs.readFileSync('apps/api/src/features/auth/sms-limits.ts', 'utf8');
|
||||
const nginx = fs.readFileSync('scripts/deploy/nginx/tjszsb.com.conf.example', 'utf8');
|
||||
const taroAuth = fs.readFileSync('apps/taro/src/services/auth.ts', 'utf8');
|
||||
|
||||
assert.match(migration, /unique index[^;]+idx_sms_codes_active_phone_reservation[\s\S]+status in \('pending', 'sent'\)/i);
|
||||
assert.match(migration, /app_private\.sms_send_rate_limits/);
|
||||
assert.match(migration, /primary key \(tenant_id, dimension, scope_hash, bucket_start\)/);
|
||||
assert.match(limits, /pg_advisory_xact_lock/);
|
||||
assert.match(limits, /SMS_TENANT_DAILY_LIMIT/);
|
||||
assert.match(limits, /SMS_PHONE_DAILY_LIMIT/);
|
||||
assert.match(limits, /SMS_IP_HOURLY_LIMIT/);
|
||||
assert.match(limits, /SMS_DEVICE_HOURLY_LIMIT/);
|
||||
assert.match(limits, /createHmac\('sha256', config\.authCodePepper\)/);
|
||||
assert.match(routes, /reserveSmsSend/);
|
||||
assert.ok(routes.indexOf('reserveSmsSend') < routes.indexOf('provider.send'), 'SMS quota must be reserved before provider cost is incurred');
|
||||
assert.match(nginx, /limit_req_zone \$binary_remote_addr zone=tiku_sms_send/);
|
||||
assert.match(nginx, /location = \/api\/auth\/sms\/send/);
|
||||
assert.match(nginx, /limit_req zone=tiku_sms_send/);
|
||||
assert.match(nginx, /proxy_set_header X-Forwarded-For \$remote_addr;/);
|
||||
assert.match(taroAuth, /deviceId: smsDeviceId\(\)/);
|
||||
|
||||
console.log('[PASS] SMS atomic quota and proxy rate-limit contract');
|
||||
@@ -2,6 +2,9 @@ import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
process.env.TARO_ENV = 'h5';
|
||||
process.env.TARO_APP_SUPABASE_URL = 'https://auth.example.test';
|
||||
process.env.TARO_APP_SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_test';
|
||||
const authModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/services/api-auth.ts`).href);
|
||||
|
||||
authModule.setSupabaseAccessTokenProviderForTest(async () => 'supabase_access_token');
|
||||
@@ -14,6 +17,16 @@ assert.equal(
|
||||
'supabase_access_token',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await authModule.resolveApiAuthorization({
|
||||
hasTokenOverride: false,
|
||||
legacyToken: 'tk_legacy_token',
|
||||
legacySource: 'app_session',
|
||||
}),
|
||||
'tk_legacy_token',
|
||||
'An explicitly active app session must remain usable for H5 SMS login',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await authModule.resolveApiAuthorization({
|
||||
authMode: 'none',
|
||||
@@ -47,10 +60,21 @@ assert.equal(
|
||||
await authModule.resolveApiAuthorization({
|
||||
hasTokenOverride: false,
|
||||
legacyToken: 'tk_legacy_token',
|
||||
legacySource: 'app_session',
|
||||
}),
|
||||
'tk_legacy_token',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await authModule.resolveApiAuthorization({
|
||||
hasTokenOverride: false,
|
||||
legacyToken: 'tk_stale_token',
|
||||
legacySource: 'supabase_jwt',
|
||||
}),
|
||||
null,
|
||||
'H5 with Supabase configured must not silently fall back to a stale non-active legacy session',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await authModule.resolveApiAuthorization({
|
||||
authMode: 'supabase',
|
||||
|
||||
52
scripts/taro-api-compatibility-contract-test.js
Normal file
52
scripts/taro-api-compatibility-contract-test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
const httpSource = read('apps/api/src/core/http.ts');
|
||||
const serverSource = read('apps/api/src/server.ts');
|
||||
const apiSource = read('apps/taro/src/services/api.ts');
|
||||
const typesSource = read('apps/taro/src/types.ts');
|
||||
const tenantLocatorSource = read('apps/api/src/features/tenant/locator.ts');
|
||||
const tenantResolutionSource = read('apps/taro/src/app/tenant-resolution.ts');
|
||||
const studentRouteSource = read('apps/api/src/features/tenant-admin/classes.ts');
|
||||
const studentCursorSource = read('apps/api/src/features/tenant-admin/student-cursor.ts');
|
||||
|
||||
assert.match(httpSource, /meta:\s*\{ \.\.\.existingMeta, requestId \}/, 'API responses must expose requestId without discarding endpoint metadata');
|
||||
assert.match(serverSource, /sendJson\(res, 200, withResponseMeta\(result, requestId\)\)/, 'Successful API responses must carry requestId metadata');
|
||||
assert.match(serverSource, /withResponseMeta\(\{ \.\.\.body, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope');
|
||||
assert.match(apiSource, /responseHeaderValue\(response\.header, 'x-request-id'\)/, 'The Taro API client must fall back to the response header requestId');
|
||||
assert.match(apiSource, /this\.requestId = payload\.requestId/, 'ApiError must retain requestId for support and observability');
|
||||
assert.doesNotMatch(typesSource, /\[key:\s*string\]:\s*unknown/, 'The shared API envelope must not silently accept arbitrary response fields');
|
||||
assert.match(typesSource, /interface ApiResponseMeta[\s\S]*requestId:\s*string/, 'The Taro response envelope must type requestId metadata');
|
||||
|
||||
assert.match(tenantLocatorSource, /TENANT_HOST_CONFLICT/, 'Tenant host conflicts must fail closed');
|
||||
assert.match(tenantLocatorSource, /TENANT_LOCATOR_REQUIRED/, 'Tenant resolution must reject a missing locator');
|
||||
assert.match(tenantResolutionSource, /tenantCode:\s*!host \|\| isLocalRuntimeHost\(host\)/, 'Production H5 host resolution must suppress tenantCode overrides');
|
||||
|
||||
assert.ok(
|
||||
studentRouteSource.includes('(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)'),
|
||||
'Deep student pages must use a composite keyset cursor',
|
||||
);
|
||||
assert.ok(
|
||||
studentRouteSource.includes('order by tm.created_at desc, tm.id desc')
|
||||
&& studentRouteSource.includes('limit $${params.length}'),
|
||||
'Student pagination must keep stable ordering and a bounded limit',
|
||||
);
|
||||
assert.match(studentRouteSource, /const hasMore = rows\.length > limit/, 'List responses must derive hasMore from a limit+1 query');
|
||||
assert.match(studentRouteSource, /nextCursor = hasMore && lastItem/, 'List responses must only issue a next cursor when another page exists');
|
||||
assert.match(studentCursorSource, /parsed\.version !== 1/, 'Opaque cursors must be versioned and fail closed');
|
||||
|
||||
const statusContracts = [
|
||||
['order', "('pending', 'paid', 'failed', 'closed', 'refunded')", read('supabase/migrations/202606210001_core_multitenant_schema.sql')],
|
||||
['refund', "('requested', 'approved', 'processing', 'succeeded', 'failed', 'rejected', 'cancelled')", read('supabase/migrations/202606290011_commerce_refunds.sql')],
|
||||
['content import', "('preview', 'pending', 'importing', 'completed', 'completed_with_errors', 'failed', 'rejected')", read('supabase/migrations/202606210007_content_import_assets.sql')],
|
||||
['CRM queue', "('pending', 'processing', 'retrying', 'sent', 'failed', 'discarded')", read('supabase/migrations/202606290010_crm_worker_hardening.sql')],
|
||||
['commission settlement', "('draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled')", read('supabase/migrations/202606290009_commission_settlements.sql')],
|
||||
];
|
||||
|
||||
for (const [name, values, source] of statusContracts) {
|
||||
assert.ok(source.includes(values), `${name} state values are part of the frontend compatibility boundary`);
|
||||
}
|
||||
|
||||
console.log('[PASS] Taro API response, tenant resolution, pagination and state-machine compatibility contract');
|
||||
253
scripts/taro-app-foundation-test.js
Normal file
253
scripts/taro-app-foundation-test.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
|
||||
|
||||
function moduleUrl(relativePath) {
|
||||
return pathToFileURL(path.join(taroSrc, relativePath)).href;
|
||||
}
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(path.join(taroSrc, relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
const storageScope = await import(moduleUrl('app/storage-scope.ts'));
|
||||
const tenantA = { portal: 'student', host: 'a.example.com' };
|
||||
const tenantB = { portal: 'student', host: 'b.example.com' };
|
||||
assert.notEqual(storageScope.tenantContextStorageKey(tenantA), storageScope.tenantContextStorageKey(tenantB));
|
||||
assert.notEqual(
|
||||
storageScope.tenantContextStorageKey(tenantA),
|
||||
storageScope.tenantContextStorageKey({ ...tenantA, portal: 'tenant-admin' }),
|
||||
);
|
||||
assert.notEqual(storageScope.sessionStorageKey(tenantA, 'tenant-a'), storageScope.sessionStorageKey(tenantA, 'tenant-b'));
|
||||
assert.notEqual(
|
||||
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'),
|
||||
storageScope.tenantDataStorageKey(tenantA, 'tenant-b', 'user-a', 'practice'),
|
||||
);
|
||||
assert.notEqual(
|
||||
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'),
|
||||
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-b', 'practice'),
|
||||
);
|
||||
assert.ok(
|
||||
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice')
|
||||
.startsWith(storageScope.userDataStoragePrefix(tenantA, 'tenant-a', 'user-a')),
|
||||
);
|
||||
assert.match(storageScope.sessionStorageKey(tenantA, 'tenant-a'), /^tiku:v2:/);
|
||||
|
||||
const tenantLaunch = await import(moduleUrl('app/tenant-launch.ts'));
|
||||
assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: 'school-a' } }), 'school-a');
|
||||
assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { scene: 'tenantCode%3Dschool-b' } }), 'school-b');
|
||||
assert.equal(tenantLaunch.tenantCodeFromLaunch({ referrerExtraData: { tenant: 'school-c' } }), 'school-c');
|
||||
assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: '../unsafe' } }), '');
|
||||
|
||||
const tenantResolution = await import(moduleUrl('app/tenant-resolution.ts'));
|
||||
assert.deepEqual(
|
||||
tenantResolution.tenantResolveQuery({ host: 'school.example.com:443', tenantCode: 'compiled-tenant' }),
|
||||
{ host: 'school.example.com:443', tenantCode: undefined },
|
||||
'A browser host must suppress a compiled tenantCode override',
|
||||
);
|
||||
assert.deepEqual(
|
||||
tenantResolution.tenantResolveQuery({ host: 'localhost:5173', tenantCode: 'school-a' }),
|
||||
{ host: 'localhost:5173', tenantCode: 'school-a' },
|
||||
'Local H5 development may select an explicit tenant code',
|
||||
);
|
||||
assert.deepEqual(
|
||||
tenantResolution.tenantResolveQuery({ host: '', tenantCode: 'school-a' }),
|
||||
{ host: undefined, tenantCode: 'school-a' },
|
||||
'Hostless WeApp resolution must retain its tenant code',
|
||||
);
|
||||
|
||||
const routePath = await import(moduleUrl('app/route-path.ts'));
|
||||
assert.equal(routePath.normalizePagePath('/pages/student/login/index'), '/pages/student/login/index');
|
||||
assert.equal(routePath.normalizePagePath('/pages/student/login'), '/pages/student/login/index');
|
||||
assert.equal(routePath.normalizePagePath('/pages/student/login/'), '/pages/student/login/index');
|
||||
assert.equal(routePath.normalizePagePath('#!/pages/bootstrap'), '/pages/bootstrap/index');
|
||||
assert.equal(routePath.normalizePagePath('/health'), '/health');
|
||||
assert.equal(
|
||||
routePath.safePageRedirectPath('/pages/student/home', 'student', '/pages/student/home/index'),
|
||||
'/pages/student/home/index',
|
||||
'clean student redirects must resolve to the registered Taro page',
|
||||
);
|
||||
assert.equal(
|
||||
routePath.safePageRedirectPath('/pages/student/practice?mode=mock', 'student', '/pages/student/home/index'),
|
||||
'/pages/student/practice/index?mode=mock',
|
||||
'safe redirect normalization must preserve local query parameters',
|
||||
);
|
||||
assert.equal(
|
||||
routePath.safePageRedirectPath('/pages/student/login', 'student', '/pages/student/home/index'),
|
||||
'/pages/student/home/index',
|
||||
'clean login redirects must not loop back to the login page',
|
||||
);
|
||||
assert.equal(
|
||||
routePath.safePageRedirectPath('/pages/student/home', 'tenant-admin', '/pages/tenant-admin/workbench/index'),
|
||||
'/pages/tenant-admin/workbench/index',
|
||||
'redirects must stay inside the compiled portal',
|
||||
);
|
||||
|
||||
const permissions = await import(moduleUrl('app/permissions.ts'));
|
||||
const tenantAccess = {
|
||||
role: 'tenant_operator',
|
||||
permissions: { 'students:read': false, '*': true },
|
||||
templatePermissions: {},
|
||||
effectivePermissions: {},
|
||||
menuPermissions: { students: false, content: true },
|
||||
modulePermissions: {},
|
||||
fieldPermissions: {},
|
||||
dataScope: {},
|
||||
roleDefaults: { tenant_operator: ['content:*'] },
|
||||
};
|
||||
assert.equal(permissions.hasTenantPermission(tenantAccess, 'students:read'), false);
|
||||
assert.equal(permissions.hasTenantPermission(tenantAccess, 'content:write'), true);
|
||||
assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'students', permission: 'students:read' }), false);
|
||||
assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'content', permission: 'content:read' }), true);
|
||||
assert.equal(permissions.hasPlatformPermission({ permissions: { '*': true }, effectivePermissions: {} }, 'platform:tenant:read'), true);
|
||||
|
||||
const theme = await import(moduleUrl('theme/tokens.ts'));
|
||||
const resolvedTheme = theme.resolveTheme({
|
||||
logoUrl: 'https://cdn.example.com/fallback.png',
|
||||
theme: {
|
||||
primaryColor: '#123456',
|
||||
accentColor: 'url(javascript:alert(1))',
|
||||
borderRadius: 99,
|
||||
buttonRadius: 7,
|
||||
customCssVars: {
|
||||
'--tiku-focus-ring': '#123abc',
|
||||
'--tiku-unsafe': 'url(https://tracker.example.com/pixel.png)',
|
||||
'--other-product': '#ffffff',
|
||||
},
|
||||
},
|
||||
publicAssets: {
|
||||
logoUrl: 'https://cdn.example.com/logo.png',
|
||||
shareImageUrl: 'https://cdn.example.com/share.png',
|
||||
},
|
||||
});
|
||||
assert.equal(resolvedTheme.tokens.primary, '#123456');
|
||||
assert.equal(resolvedTheme.tokens.accent, theme.defaultThemeTokens.accent);
|
||||
assert.equal(resolvedTheme.tokens.radius, '32px');
|
||||
assert.equal(resolvedTheme.tokens.radiusSmall, '7px');
|
||||
assert.deepEqual(resolvedTheme.customCssVars, { '--tiku-focus-ring': '#123abc' });
|
||||
assert.equal(resolvedTheme.assets.logoUrl, 'https://cdn.example.com/logo.png');
|
||||
assert.equal(theme.themeCssVariables(resolvedTheme.tokens)['--tiku-primary'], '#123456');
|
||||
|
||||
const sessionEvents = await import(moduleUrl('app/session-events.ts'));
|
||||
const reasons = [];
|
||||
const unsubscribe = sessionEvents.subscribeSessionChanges(reason => reasons.push(reason));
|
||||
sessionEvents.emitSessionChange('cleared');
|
||||
sessionEvents.emitSessionChange('cleared');
|
||||
sessionEvents.emitSessionChange('saved');
|
||||
sessionEvents.emitSessionChange('cleared');
|
||||
unsubscribe();
|
||||
assert.deepEqual(reasons, ['cleared', 'saved', 'cleared']);
|
||||
assert.match(readSource('app/session-events.ts'), /addEventListener\('storage'/);
|
||||
assert.match(readSource('app/session-events.ts'), /h5SessionEventKey/);
|
||||
|
||||
const appSource = readSource('app.tsx');
|
||||
assert.match(appSource, /<AppProvider path=\{path\}>/);
|
||||
assert.match(appSource, /<ThemeProvider>/);
|
||||
assert.match(appSource, /className=\{routeReady \? '' : 'route-guard-hidden'\}/);
|
||||
assert.match(appSource, /\{content\}/, 'Taro page content must stay mounted while the route guard overlay is visible');
|
||||
assert.doesNotMatch(appSource, /routeReady \? content : null/, 'Route readiness must not remove the Taro page instance');
|
||||
assert.match(appSource, /useRouter\(true\)/, 'App must react to WeApp and subpackage route changes');
|
||||
assert.match(appSource, /normalizePagePath\(router\.path/);
|
||||
assert.match(appSource, /applyWeappLaunchTenant\(\)/);
|
||||
assert.match(appSource, /identityKey/);
|
||||
assert.match(appSource, /key=\{identityKey\}/);
|
||||
assert.doesNotMatch(appSource, /katex\/dist\/katex\.min\.css/, 'KaTeX CSS must not stay in the global app entry');
|
||||
assert.match(readSource('components/RichContent.tsx'), /katex-platform\.css/);
|
||||
assert.match(readSource('components/katex-platform.h5.css'), /katex\/dist\/katex\.min\.css/);
|
||||
assert.doesNotMatch(readSource('components/katex-platform.css'), /katex\/dist\/katex\.min\.css/);
|
||||
|
||||
const navigationSource = readSource('capabilities/navigation.ts');
|
||||
assert.match(navigationSource, /taroWeappTenantMode\(\) !== 'launch'/, 'Fixed WeApp builds must ignore launch tenant overrides');
|
||||
assert.match(navigationSource, /appEnv\.tenantCode = ''/, 'Launch mode must clear any compiled tenant fallback before parsing launch data');
|
||||
assert.match(navigationSource, /tenantCodeFromLaunch/, 'Launch mode must parse query, scene, and referrer tenant data');
|
||||
|
||||
const themeProviderSource = readSource('theme/ThemeProvider.tsx');
|
||||
assert.match(themeProviderSource, /data-tiku-theme-managed/);
|
||||
assert.match(themeProviderSource, /updateManagedMeta\([^\n]+assets\.shareImageUrl\)/);
|
||||
assert.match(themeProviderSource, /updateManagedFavicon\(assets\.faviconUrl\)/);
|
||||
assert.match(themeProviderSource, /originalHrefAttribute/);
|
||||
assert.match(themeProviderSource, /managedCustomCssVars/);
|
||||
assert.match(themeProviderSource, /removeProperty\(key\)/);
|
||||
assert.match(themeProviderSource, /\.\.\.resolved\.customCssVars/);
|
||||
|
||||
const loginSource = readSource('pages/student/login/index.tsx');
|
||||
assert.match(loginSource, /bootstrapStatus === 'forbidden' \? bootstrapError : ''/);
|
||||
assert.doesNotMatch(loginSource, /bootstrapStatus === 'unauthenticated' \? bootstrapError/, '401 must not be presented as a permission failure');
|
||||
|
||||
const tenantSettingsSource = readSource('pages/tenant-admin/settings/index.tsx');
|
||||
assert.match(tenantSettingsSource, /await publishTenantTheme/);
|
||||
assert.match(tenantSettingsSource, /await refreshTenant\(\)/, 'published branding must refresh the active ThemeProvider');
|
||||
|
||||
for (const shellPath of ['components/AdminLegacyShell.tsx', 'components/StudentLegacyShell.tsx']) {
|
||||
const source = readSource(shellPath);
|
||||
assert.match(source, /useApp\(\)/, `${shellPath} must consume AppProvider state`);
|
||||
assert.doesNotMatch(source, /Taro\.(?:navigateTo|redirectTo|reLaunch)/, `${shellPath} must use navigation capability`);
|
||||
}
|
||||
|
||||
const apiSource = readSource('services/api.ts');
|
||||
assert.match(apiSource, /currentTenantContextStorageKey/);
|
||||
assert.match(apiSource, /currentSessionStorageKey/);
|
||||
assert.match(apiSource, /expiresAt <= Date\.now\(\)/);
|
||||
assert.match(apiSource, /emitSessionChange\('expired'\)/);
|
||||
assert.match(apiSource, /signOut\(\{ scope: 'local' \}\)/);
|
||||
assert.match(apiSource, /clearActiveStorageUserData\(tenant\.tenantId\)/);
|
||||
assert.match(apiSource, /tenantResolveQuery/, 'Tenant resolution requests must use the host-authority contract');
|
||||
assert.match(apiSource, /TENANT_DOMAIN_NOT_BOUND/, 'An unbound domain must clear stale tenant context');
|
||||
|
||||
const appProviderSource = readSource('app/AppProvider.tsx');
|
||||
assert.match(appProviderSource, /appEnv\.portal === 'platform-admin'\s*\? null/, 'Platform portal must not require a business tenant at bootstrap');
|
||||
assert.match(appProviderSource, /appEnv\.portal !== 'platform-admin' && !tenant/, 'Only tenant-scoped portals may resolve a business tenant');
|
||||
assert.match(appProviderSource, /H5 租户由当前域名确定/, 'H5 tenant switching must not override the authoritative host');
|
||||
assert.match(apiSource, /clearSession\(options: \{ emit\?: boolean \} = \{\}\)/);
|
||||
assert.match(apiSource, /rejectedToken.*currentToken/s);
|
||||
|
||||
const storageCapabilitySource = readSource('capabilities/storage.ts');
|
||||
assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\)/);
|
||||
assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\) \|\| 'anonymous'/);
|
||||
assert.match(storageCapabilitySource, /removeStorageByPrefix\(userDataStoragePrefix/);
|
||||
assert.match(storageCapabilitySource, /previousUserId !== userId/);
|
||||
assert.match(storageCapabilitySource, /legacyTenantDataStoragePrefix/);
|
||||
assert.match(storageCapabilitySource, /tiku:practice:/);
|
||||
assert.match(readSource('services/storage.ts'), /getActiveStorageUserId\(scope\.tenantId\) === scope\.userId/);
|
||||
assert.match(readSource('app/AppProvider.tsx'), /activateStorageUser\(tenant\.tenantId, currentUser\.id\)/);
|
||||
assert.match(readSource('app/AppProvider.tsx'), /event === 'SIGNED_IN'\) clearSession\(\{ emit: false \}\)/);
|
||||
|
||||
const authSource = readSource('services/auth.ts');
|
||||
assert.match(authSource, /clearSession\(\{ emit: false \}\)/);
|
||||
assert.match(authSource, /emitSessionChange\('cleared'\)/);
|
||||
assert.match(authSource, /source: 'app_session'/);
|
||||
assert.match(readSource('app/AppProvider.tsx'), /payload\.session\.source !== 'app_session'/);
|
||||
|
||||
for (const pagePath of ['pages/student/practice/index.tsx', 'pages/student/vocabulary/index.tsx']) {
|
||||
const source = readSource(pagePath);
|
||||
assert.match(source, /createUserStorage/);
|
||||
assert.match(source, /currentUser\?\.id/);
|
||||
}
|
||||
|
||||
const routeGuardSource = readSource('services/routeGuard.ts');
|
||||
assert.match(routeGuardSource, /normalizePagePath.*@\/app\/route-path/);
|
||||
assert.match(routeGuardSource, /export \{ normalizePagePath \}/);
|
||||
assert.match(routeGuardSource, /safePageRedirectPath\(path, appEnv\.portal, landingPath\(\)\)/);
|
||||
assert.match(readSource('app/route-path.ts'), /path\.indexOf\('pages\/'\)/, 'route normalization must preserve student subpackage paths');
|
||||
|
||||
for (const pagePath of [
|
||||
'pages/platform-admin/workbench/index.tsx',
|
||||
'pages/tenant-admin/content/index.tsx',
|
||||
'pages/tenant-admin/marketing/index.tsx',
|
||||
'pages/student/ai-school/index.tsx',
|
||||
'pages/student/checkout/index.tsx',
|
||||
'pages/student/order-detail/index.tsx',
|
||||
]) {
|
||||
const source = readSource(pagePath);
|
||||
assert.doesNotMatch(source, /document\.createElement|window\.location/, `${pagePath} must use a cross-platform capability`);
|
||||
}
|
||||
|
||||
const paymentAdapter = readSource('capabilities/payment.ts');
|
||||
assert.match(paymentAdapter, /isWeappRuntime\(\)/);
|
||||
assert.match(paymentAdapter, /Taro\.requestPayment/);
|
||||
|
||||
console.log('[PASS] Taro app foundation contracts');
|
||||
275
scripts/taro-build-matrix-contract-test.js
Normal file
275
scripts/taro-build-matrix-contract-test.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const taroRoot = path.join(repoRoot, 'apps', 'taro');
|
||||
const configPath = path.join(taroRoot, 'config', 'index.ts');
|
||||
const appConfigPath = path.join(taroRoot, 'src', 'app.config.ts');
|
||||
const projectConfigPath = path.join(taroRoot, 'project.config.json');
|
||||
const rootPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
const taroPackage = JSON.parse(fs.readFileSync(path.join(taroRoot, 'package.json'), 'utf8'));
|
||||
const bootstrapSource = fs.readFileSync(path.join(taroRoot, 'src', 'pages', 'bootstrap', 'index.tsx'), 'utf8');
|
||||
const h5InteractionSmokeSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'taro-h5-interaction-smoke.js'), 'utf8');
|
||||
const h5RuntimePatchCheck = 'node ../../scripts/taro-components-h5-runtime-patch.js --check';
|
||||
const weappBuildModule = await import(pathToFileURL(path.join(repoRoot, 'scripts', 'build-weapp-student.js')).href);
|
||||
const weappGuardModule = await import(pathToFileURL(path.join(repoRoot, 'scripts', 'taro-weapp-release-guardrails.js')).href);
|
||||
|
||||
const matrix = [
|
||||
{ id: 'h5.student', taroEnv: 'h5', portal: 'student', releaseMode: 'production', outputRoot: 'dist/h5-student', appScript: 'build:h5:student', rootScript: 'build:taro:h5:student' },
|
||||
{ id: 'h5.tenant', taroEnv: 'h5', portal: 'tenant-admin', releaseMode: 'production', outputRoot: 'dist/h5-tenant-admin', appScript: 'build:h5:tenant', rootScript: 'build:taro:h5:tenant' },
|
||||
{ id: 'h5.platform', taroEnv: 'h5', portal: 'platform-admin', releaseMode: 'production', outputRoot: 'dist/h5-platform-admin', appScript: 'build:h5:platform', rootScript: 'build:taro:h5:platform' },
|
||||
{ id: 'weapp.student', taroEnv: 'weapp', portal: 'student', releaseMode: 'preview', outputRoot: 'dist/weapp-student', appScript: 'build:weapp:student', rootScript: 'build:taro:weapp:student' },
|
||||
];
|
||||
|
||||
async function importForBuild(filePath, build) {
|
||||
process.env.TARO_ENV = build.taroEnv;
|
||||
process.env.TARO_APP_PORTAL = build.portal;
|
||||
process.env.TARO_APP_RELEASE_MODE = build.releaseMode || 'preview';
|
||||
if (build.apiBaseUrl === undefined) delete process.env.TARO_APP_API_BASE_URL;
|
||||
else process.env.TARO_APP_API_BASE_URL = build.apiBaseUrl;
|
||||
if (build.tenantCode === undefined) delete process.env.TARO_APP_TENANT_CODE;
|
||||
else process.env.TARO_APP_TENANT_CODE = build.tenantCode;
|
||||
if (build.weappTenantMode === undefined) delete process.env.TARO_APP_WEAPP_TENANT_MODE;
|
||||
else process.env.TARO_APP_WEAPP_TENANT_MODE = build.weappTenantMode;
|
||||
globalThis.defineAppConfig = value => value;
|
||||
const moduleUrl = pathToFileURL(filePath);
|
||||
moduleUrl.searchParams.set('matrix', build.id);
|
||||
return (await import(moduleUrl.href)).default;
|
||||
}
|
||||
|
||||
const originalPortal = process.env.TARO_APP_PORTAL;
|
||||
const originalTaroEnv = process.env.TARO_ENV;
|
||||
const originalReleaseMode = process.env.TARO_APP_RELEASE_MODE;
|
||||
const originalApiBaseUrl = process.env.TARO_APP_API_BASE_URL;
|
||||
const originalTenantCode = process.env.TARO_APP_TENANT_CODE;
|
||||
const originalWeappTenantMode = process.env.TARO_APP_WEAPP_TENANT_MODE;
|
||||
const observedOutputRoots = [];
|
||||
|
||||
for (const build of matrix) {
|
||||
const config = await importForBuild(configPath, build);
|
||||
const appConfig = await importForBuild(appConfigPath, build);
|
||||
assert.equal(config.outputRoot, build.outputRoot, `${build.id} must write to ${build.outputRoot}`);
|
||||
if (build.taroEnv === 'h5') {
|
||||
assert.notEqual(config.h5?.useDeprecatedAdapterComponent, true, `${build.id} must keep the reviewed modern Taro component adapter`);
|
||||
assert.equal(config.h5?.devServer?.host, '127.0.0.1', `${build.id} development server must bind to loopback`);
|
||||
assert.deepEqual(
|
||||
config.h5?.devServer?.allowedHosts,
|
||||
['localhost', '127.0.0.1'],
|
||||
`${build.id} development server must reject untrusted Host headers`,
|
||||
);
|
||||
}
|
||||
const publicBuildConfig = JSON.parse(config.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}');
|
||||
assert.equal(publicBuildConfig.portal, build.portal, `${build.id} must compile its portal into the public build config`);
|
||||
assert.equal(publicBuildConfig.target, build.taroEnv, `${build.id} must compile its target into the public build config`);
|
||||
assert.equal(publicBuildConfig.releaseMode, build.releaseMode, `${build.id} must compile its release mode into the public build config`);
|
||||
assert.equal(publicBuildConfig.weappTenantMode, build.taroEnv === 'weapp' ? 'launch' : '', `${build.id} must compile its WeApp tenant mode`);
|
||||
if (build.releaseMode === 'production') {
|
||||
assert.doesNotMatch(JSON.stringify(publicBuildConfig), /(?:127\.0\.0\.1|localhost)/i, `${build.id} must not compile a local API fallback`);
|
||||
}
|
||||
observedOutputRoots.push(config.outputRoot);
|
||||
|
||||
const appScript = taroPackage.scripts?.[build.appScript] || '';
|
||||
const rootScript = rootPackage.scripts?.[build.rootScript] || '';
|
||||
if (build.taroEnv === 'weapp') {
|
||||
assert.ok(appScript.includes('build-weapp-student.js'), `${build.appScript} must use the guarded WeApp build wrapper`);
|
||||
} else {
|
||||
assert.ok(appScript.startsWith(`${h5RuntimePatchCheck} && `), `${build.appScript} must verify the reviewed Taro H5 runtime patches`);
|
||||
assert.ok(appScript.includes(`TARO_ENV=${build.taroEnv}`), `${build.appScript} must pin TARO_ENV=${build.taroEnv}`);
|
||||
assert.ok(appScript.includes(`TARO_APP_PORTAL=${build.portal}`), `${build.appScript} must pin TARO_APP_PORTAL=${build.portal}`);
|
||||
assert.ok(appScript.includes('TARO_APP_RELEASE_MODE=production'), `${build.appScript} must fail closed on missing production runtime config`);
|
||||
assert.ok(appScript.includes(`--type ${build.taroEnv}`), `${build.appScript} must build the ${build.taroEnv} target`);
|
||||
}
|
||||
assert.ok(rootScript.includes('@tiku-saas/taro') && rootScript.includes(build.appScript), `${build.rootScript} must delegate to the Taro workspace script`);
|
||||
|
||||
if (build.taroEnv === 'h5') {
|
||||
assert.equal(appConfig.subPackages, undefined, `${build.id} must not emit mini-program subpackages`);
|
||||
} else {
|
||||
assert.deepEqual(appConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must stay minimal');
|
||||
assert.equal(appConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp must keep all business pages in the student subpackage');
|
||||
assert.equal(appConfig.lazyCodeLoading, 'requiredComponents', 'Student WeApp must enable required-component lazy loading');
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(new Set(observedOutputRoots).size, matrix.length, 'Every platform/portal build must have an isolated output directory');
|
||||
assert.ok(!taroPackage.scripts?.['build:weapp:tenant'], 'Tenant admin is H5-only and must not expose a WeApp build');
|
||||
assert.ok(!taroPackage.scripts?.['build:weapp:platform'], 'Platform admin is H5-only and must not expose a WeApp build');
|
||||
assert.ok(rootPackage.scripts?.['build:taro:h5:preview'], 'Root package must expose the three-portal H5 preview build');
|
||||
assert.equal(
|
||||
taroPackage.scripts?.postinstall,
|
||||
'node ../../scripts/taro-components-h5-runtime-patch.js --apply',
|
||||
'Taro workspace installation must apply the reviewed H5 runtime patches',
|
||||
);
|
||||
assert.ok(taroPackage.scripts?.['dev:h5']?.startsWith(`${h5RuntimePatchCheck} && `), 'H5 development must verify the runtime patches');
|
||||
for (const item of [
|
||||
['student', 'student'],
|
||||
['tenant', 'tenant-admin'],
|
||||
['platform', 'platform-admin'],
|
||||
]) {
|
||||
const [scriptSuffix, portal] = item;
|
||||
const appScriptName = `build:h5:${scriptSuffix}:preview`;
|
||||
const rootScriptName = `build:taro:h5:${scriptSuffix}:preview`;
|
||||
const appScript = taroPackage.scripts?.[appScriptName] || '';
|
||||
const rootScript = rootPackage.scripts?.[rootScriptName] || '';
|
||||
assert.ok(appScript.startsWith(`${h5RuntimePatchCheck} && `), `${appScriptName} must verify the reviewed Taro H5 runtime patches`);
|
||||
assert.ok(appScript.includes(`TARO_APP_PORTAL=${portal}`), `${appScriptName} must pin TARO_APP_PORTAL=${portal}`);
|
||||
assert.ok(appScript.includes('TARO_APP_RELEASE_MODE=preview'), `${appScriptName} must compile preview mode`);
|
||||
assert.ok(rootScript.includes(appScriptName), `${rootScriptName} must delegate to ${appScriptName}`);
|
||||
}
|
||||
assert.match(h5InteractionSmokeSource, /assertServeOnlyPreviewBuild/);
|
||||
assert.match(h5InteractionSmokeSource, /not a preview build/);
|
||||
assert.match(h5InteractionSmokeSource, /assertRuntimeHealthy/);
|
||||
assert.match(h5InteractionSmokeSource, /Runtime\.exceptionThrown/);
|
||||
assert.match(h5InteractionSmokeSource, /runCrossPortalRuntimeProbe/);
|
||||
assert.match(h5InteractionSmokeSource, /runTaroInputWatcherRaceProbe/);
|
||||
assert.match(h5InteractionSmokeSource, /runTaroButtonLoadingRaceProbe/);
|
||||
|
||||
for (const portal of ['tenant-admin', 'platform-admin']) {
|
||||
await assert.rejects(
|
||||
() => importForBuild(appConfigPath, { id: `unsupported.weapp.${portal}`, taroEnv: 'weapp', portal }),
|
||||
/H5-only/,
|
||||
`${portal} WeApp configuration must fail fast instead of producing an unsupported admin mini-program`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8'));
|
||||
assert.equal(projectConfig.miniprogramRoot, 'dist/weapp-student/', 'WeChat developer tools must open the isolated student WeApp output');
|
||||
assert.ok(
|
||||
bootstrapSource.includes('redirectToLogin') && bootstrapSource.includes('if (!authorized)'),
|
||||
'Student WeApp bootstrap page must route unauthenticated users into the login page inside the student subpackage',
|
||||
);
|
||||
assert.ok(taroPackage.scripts?.['build:weapp:student:production']?.includes('--production'), 'Production WeApp build must use strict public config validation');
|
||||
assert.ok(rootPackage.scripts?.['build:taro:weapp:student:production'], 'Root package must expose the production WeApp build');
|
||||
|
||||
const productionWeappConfig = await importForBuild(configPath, {
|
||||
id: 'weapp.student.production',
|
||||
taroEnv: 'weapp',
|
||||
portal: 'student',
|
||||
releaseMode: 'production',
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
tenantCode: 'campus-north',
|
||||
weappTenantMode: 'fixed',
|
||||
});
|
||||
const productionPublicConfig = JSON.parse(productionWeappConfig.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}');
|
||||
assert.deepEqual(
|
||||
productionPublicConfig,
|
||||
{
|
||||
portal: 'student',
|
||||
target: 'weapp',
|
||||
releaseMode: 'production',
|
||||
weappTenantMode: 'fixed',
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
supabaseUrl: '',
|
||||
supabasePublishableKey: '',
|
||||
tenantCode: 'campus-north',
|
||||
},
|
||||
'Production WeApp public config must be fully resolved at compile time',
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(productionPublicConfig), /(?:127\.0\.0\.1|localhost)/i, 'Production WeApp compile constants must not contain local fallbacks');
|
||||
|
||||
const launchWeappConfig = await importForBuild(configPath, {
|
||||
id: 'weapp.student.launch',
|
||||
taroEnv: 'weapp',
|
||||
portal: 'student',
|
||||
releaseMode: 'production',
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
tenantCode: 'inherited-tenant',
|
||||
weappTenantMode: 'launch',
|
||||
});
|
||||
const launchPublicConfig = JSON.parse(launchWeappConfig.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}');
|
||||
assert.equal(launchPublicConfig.weappTenantMode, 'launch');
|
||||
assert.equal(launchPublicConfig.tenantCode, '', 'Launch mode must clear an inherited compile-time tenant code');
|
||||
|
||||
assert.deepEqual(
|
||||
weappBuildModule.resolveWeappBuildConfig({
|
||||
TARO_APP_API_BASE_URL: 'https://api.gongxue100.com/',
|
||||
WECHAT_MINIAPP_APP_ID: 'wx6f3a9c2d4e8b1a70',
|
||||
TARO_APP_WEAPP_TENANT_MODE: 'fixed',
|
||||
TARO_APP_TENANT_CODE: 'campus-north',
|
||||
}, { production: true }),
|
||||
{
|
||||
production: true,
|
||||
tenantMode: 'fixed',
|
||||
tenantCode: 'campus-north',
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
appId: 'wx6f3a9c2d4e8b1a70',
|
||||
},
|
||||
);
|
||||
assert.equal(weappBuildModule.resolveWeappTenantMode({}, false), 'launch');
|
||||
assert.equal(weappBuildModule.resolveWeappTenantMode({ TARO_APP_TENANT_CODE: 'campus-north' }, false), 'fixed');
|
||||
assert.throws(
|
||||
() => weappBuildModule.resolveWeappBuildConfig({
|
||||
TARO_APP_API_BASE_URL: 'https://api.gongxue100.com',
|
||||
WECHAT_MINIAPP_APP_ID: 'wx6f3a9c2d4e8b1a70',
|
||||
TARO_APP_WEAPP_TENANT_MODE: 'launch',
|
||||
TARO_APP_TENANT_CODE: 'inherited-tenant',
|
||||
}, { production: true }),
|
||||
/must be empty.*launch/,
|
||||
);
|
||||
for (const apiBaseUrl of [
|
||||
'http://api.gongxue100.com',
|
||||
'https://localhost:8787',
|
||||
'https://127.0.0.1:8787',
|
||||
'https://[::1]:8787',
|
||||
'https://api.example',
|
||||
'https://api.example.test',
|
||||
'https://api.internal.local',
|
||||
]) {
|
||||
assert.throws(() => weappBuildModule.validateProductionApiBaseUrl(apiBaseUrl), /TARO_APP_API_BASE_URL/);
|
||||
}
|
||||
for (const appId of ['touristappid', 'wx0000000000000000', 'wx0123456789abcdef']) {
|
||||
assert.throws(() => weappBuildModule.validateProductionWechatAppId(appId), /WECHAT_MINIAPP_APP_ID/);
|
||||
}
|
||||
for (const tenantCode of ['tenant-production', 'replace-with-tenant-code', 'example', 'test', 'demo', 'smoke', 'placeholder', 'changeme']) {
|
||||
assert.throws(() => weappBuildModule.validateProductionTenantCode(tenantCode), /placeholder tenant code/);
|
||||
}
|
||||
|
||||
const guardFixture = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-weapp-guard-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(guardFixture, 'pages', 'student'), { recursive: true });
|
||||
fs.writeFileSync(path.join(guardFixture, 'project.config.json'), JSON.stringify({
|
||||
appid: 'wx6f3a9c2d4e8b1a70',
|
||||
setting: { urlCheck: true },
|
||||
}));
|
||||
fs.writeFileSync(path.join(guardFixture, 'app.json'), JSON.stringify({
|
||||
pages: ['pages/bootstrap/index'],
|
||||
subPackages: [{ root: 'pages/student', pages: ['home/index'] }],
|
||||
}));
|
||||
fs.writeFileSync(path.join(guardFixture, 'common.js'), `const config=${JSON.stringify(productionPublicConfig)};`);
|
||||
fs.writeFileSync(path.join(guardFixture, 'pages', 'student', 'home.js'), 'module.exports = {};');
|
||||
const releaseChecks = weappGuardModule.inspectWeappRelease({ distRoot: guardFixture, requireProduction: true });
|
||||
assert.equal(releaseChecks.some(check => check.status === 'fail'), false, JSON.stringify(releaseChecks));
|
||||
|
||||
fs.writeFileSync(path.join(guardFixture, 'common.js'), `const config=${JSON.stringify({
|
||||
...productionPublicConfig,
|
||||
weappTenantMode: 'launch',
|
||||
tenantCode: 'compiled-fallback',
|
||||
})};`);
|
||||
const unsafeLaunchChecks = weappGuardModule.inspectWeappRelease({ distRoot: guardFixture, requireProduction: true });
|
||||
assert.ok(unsafeLaunchChecks.some(check => check.id === 'weapp.public_config.tenant_code' && check.status === 'fail'));
|
||||
} finally {
|
||||
fs.rmSync(guardFixture, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const invalid of [
|
||||
{ id: 'invalid.portal', taroEnv: 'h5', portal: 'studnet' },
|
||||
{ id: 'invalid.target', taroEnv: '../h5', portal: 'student' },
|
||||
]) {
|
||||
await assert.rejects(() => importForBuild(configPath, invalid), /Unsupported Taro/, `${invalid.id} must fail fast`);
|
||||
}
|
||||
|
||||
for (const [key, value] of [
|
||||
['TARO_APP_PORTAL', originalPortal],
|
||||
['TARO_ENV', originalTaroEnv],
|
||||
['TARO_APP_RELEASE_MODE', originalReleaseMode],
|
||||
['TARO_APP_API_BASE_URL', originalApiBaseUrl],
|
||||
['TARO_APP_TENANT_CODE', originalTenantCode],
|
||||
['TARO_APP_WEAPP_TENANT_MODE', originalWeappTenantMode],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
|
||||
console.log(`[PASS] Taro build matrix contract (${matrix.map(item => `${item.id}=${item.outputRoot}`).join(', ')})`);
|
||||
139
scripts/taro-components-h5-runtime-patch-test.js
Normal file
139
scripts/taro-components-h5-runtime-patch-test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
enforceTaroH5RuntimePatches,
|
||||
replaceExactlyOnce,
|
||||
taroButtonLoadingPatch,
|
||||
taroH5RuntimePatchDefinition,
|
||||
taroInputWatcherPatch,
|
||||
} from './taro-components-h5-runtime-patch.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function installedPristineSource(patch) {
|
||||
const target = path.join(repoRoot, 'node_modules', '@tarojs', 'components', patch.targetRelativePath);
|
||||
const installed = fs.readFileSync(target, 'utf8');
|
||||
const pristine = installed.includes(patch.after) ? installed.replace(patch.after, patch.before) : installed;
|
||||
return pristine;
|
||||
}
|
||||
|
||||
const pristineSources = new Map([
|
||||
[taroInputWatcherPatch.id, installedPristineSource(taroInputWatcherPatch)],
|
||||
[taroButtonLoadingPatch.id, installedPristineSource(taroButtonLoadingPatch)],
|
||||
]);
|
||||
|
||||
function createFixture(options = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-taro-h5-runtime-patch-'));
|
||||
const taroManifest = {
|
||||
name: '@tiku-saas/taro',
|
||||
version: '0.1.0',
|
||||
scripts: { postinstall: taroH5RuntimePatchDefinition.postinstallCommand },
|
||||
devDependencies: {
|
||||
'@tarojs/components': options.declaredVersion || taroH5RuntimePatchDefinition.packageVersion,
|
||||
},
|
||||
};
|
||||
writeJson(path.join(root, 'apps', 'taro', 'package.json'), taroManifest);
|
||||
writeJson(path.join(root, 'package-lock.json'), {
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'apps/taro': {
|
||||
hasInstallScript: true,
|
||||
devDependencies: {
|
||||
'@tarojs/components': options.lockWorkspaceVersion || taroH5RuntimePatchDefinition.packageVersion,
|
||||
},
|
||||
},
|
||||
'node_modules/@tarojs/components': {
|
||||
version: options.lockVersion || taroH5RuntimePatchDefinition.packageVersion,
|
||||
integrity: options.integrity || taroH5RuntimePatchDefinition.lockIntegrity,
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(path.join(root, 'node_modules', '@tarojs', 'components', 'package.json'), {
|
||||
name: '@tarojs/components',
|
||||
version: options.installedVersion || taroH5RuntimePatchDefinition.packageVersion,
|
||||
});
|
||||
|
||||
const targets = new Map();
|
||||
for (const patch of taroH5RuntimePatchDefinition.patches) {
|
||||
const target = path.join(root, 'node_modules', '@tarojs', 'components', patch.targetRelativePath);
|
||||
targets.set(patch.id, target);
|
||||
if (options.missingTarget !== patch.id) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
target,
|
||||
options.sources?.[patch.id] ?? pristineSources.get(patch.id),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
}
|
||||
return { root, targets };
|
||||
}
|
||||
|
||||
function targetContents(fixture) {
|
||||
return Object.fromEntries([...fixture.targets].map(([id, target]) => [
|
||||
id,
|
||||
fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null,
|
||||
]));
|
||||
}
|
||||
|
||||
const workingState = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' });
|
||||
assert.equal(workingState.status, 'pass');
|
||||
assert.equal(workingState.patches.inputWatcher.installedSha256, taroInputWatcherPatch.patchedSha256);
|
||||
assert.equal(workingState.patches.buttonLoading.installedSha256, taroButtonLoadingPatch.patchedSha256);
|
||||
|
||||
const applied = createFixture();
|
||||
try {
|
||||
const first = enforceTaroH5RuntimePatches({ root: applied.root, mode: 'apply' });
|
||||
assert.equal(first.patches.inputWatcher.state, 'patched-now');
|
||||
assert.equal(first.patches.buttonLoading.state, 'patched-now');
|
||||
const second = enforceTaroH5RuntimePatches({ root: applied.root, mode: 'apply' });
|
||||
assert.equal(second.patches.inputWatcher.state, 'patched');
|
||||
assert.equal(second.patches.buttonLoading.state, 'patched');
|
||||
assert.equal(enforceTaroH5RuntimePatches({ root: applied.root, mode: 'check' }).status, 'pass');
|
||||
} finally {
|
||||
fs.rmSync(applied.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const pristineCheck = createFixture();
|
||||
try {
|
||||
const before = targetContents(pristineCheck);
|
||||
assert.throws(
|
||||
() => enforceTaroH5RuntimePatches({ root: pristineCheck.root, mode: 'check' }),
|
||||
/patch is not applied/,
|
||||
);
|
||||
assert.deepEqual(targetContents(pristineCheck), before, '--check must never modify a pristine install');
|
||||
} finally {
|
||||
fs.rmSync(pristineCheck.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const [name, options, pattern] of [
|
||||
['declared version', { declaredVersion: '4.2.1' }, /must pin/],
|
||||
['lock integrity', { integrity: 'sha512-unreviewed' }, /unexpected.*integrity/],
|
||||
['installed version', { installedVersion: '4.2.1-beta.2' }, /does not match/],
|
||||
['missing Input target', { missingTarget: taroInputWatcherPatch.id }, /target is missing/],
|
||||
['missing Button target', { missingTarget: taroButtonLoadingPatch.id }, /target is missing/],
|
||||
['unknown Input content', { sources: { [taroInputWatcherPatch.id]: `${pristineSources.get(taroInputWatcherPatch.id)}\n// tampered\n` } }, /unreviewed.*hash/],
|
||||
['unknown Button content', { sources: { [taroButtonLoadingPatch.id]: `${pristineSources.get(taroButtonLoadingPatch.id)}\n// tampered\n` } }, /unreviewed.*hash/],
|
||||
]) {
|
||||
const fixture = createFixture(options);
|
||||
try {
|
||||
const before = targetContents(fixture);
|
||||
assert.throws(() => enforceTaroH5RuntimePatches({ root: fixture.root, mode: 'apply' }), pattern, name);
|
||||
assert.deepEqual(targetContents(fixture), before, `${name} failure must not modify either target`);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
assert.throws(() => replaceExactlyOnce('safe', 'unsafe', 'guarded'), /found 0/);
|
||||
assert.throws(() => replaceExactlyOnce('unsafe unsafe', 'unsafe', 'guarded'), /found 2/);
|
||||
assert.equal(replaceExactlyOnce('before unsafe after', 'unsafe', 'guarded'), 'before guarded after');
|
||||
|
||||
console.log('[PASS] Taro H5 runtime patch contract');
|
||||
198
scripts/taro-components-h5-runtime-patch.js
Normal file
198
scripts/taro-components-h5-runtime-patch.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const postinstallCommand = 'node ../../scripts/taro-components-h5-runtime-patch.js --apply';
|
||||
|
||||
const packageContract = Object.freeze({
|
||||
packageName: '@tarojs/components',
|
||||
packageVersion: '4.2.0',
|
||||
lockIntegrity: 'sha512-SQIK5UxKfmkhV0MdhmC0KV6duSkBtF9C8sX3dF6afKX8DvULXP5dirH8Y4bJEL+Mt+dkhlXiBmuSk/C2KB7sVg==',
|
||||
});
|
||||
|
||||
export const taroInputWatcherPatch = Object.freeze({
|
||||
id: 'inputWatcher',
|
||||
label: 'Input watcher',
|
||||
targetRelativePath: 'dist/components/taro-input-core.js',
|
||||
pristineSha256: '2483ffc5727959174e988c7171d7f7bb0a6300851cae1a13699d62a7d4769f95',
|
||||
patchedSha256: '260bb8a07d66eaf3398904acb94a7c2cacabe4411b70a01fb0d03931fe95c499',
|
||||
before: 'if (this.inputRef.value !== value) {',
|
||||
after: 'if (this.inputRef && this.inputRef.value !== value) {',
|
||||
});
|
||||
|
||||
export const taroButtonLoadingPatch = Object.freeze({
|
||||
id: 'buttonLoading',
|
||||
label: 'Button loading node',
|
||||
targetRelativePath: 'dist/components/taro-button-core.js',
|
||||
pristineSha256: 'de5dfab0fc4c68a388b996b059255c57cc1ec52891238e7aacea588e7a9dd63e',
|
||||
patchedSha256: '428db74e51382c68bc10211ff7815d494b086de465fdef97ca09f5b7ab8368ea',
|
||||
before: 'loading && h("i", { class: \'weui-loading\' })',
|
||||
after: 'h("i", { class: \'weui-loading\', style: { display: loading ? \'inline-block\' : \'none\' } })',
|
||||
});
|
||||
|
||||
export const taroH5RuntimePatchDefinition = Object.freeze({
|
||||
...packageContract,
|
||||
postinstallCommand,
|
||||
patches: Object.freeze([taroInputWatcherPatch, taroButtonLoadingPatch]),
|
||||
});
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function occurrences(source, needle) {
|
||||
return source.split(needle).length - 1;
|
||||
}
|
||||
|
||||
export function replaceExactlyOnce(source, before, after, label = 'Taro H5 runtime patch') {
|
||||
const count = occurrences(source, before);
|
||||
assert(count === 1, `expected the ${label} target exactly once, found ${count}`);
|
||||
return source.replace(before, after);
|
||||
}
|
||||
|
||||
function validateRepositoryContract(root, definition) {
|
||||
const taroManifestPath = path.join(root, 'apps', 'taro', 'package.json');
|
||||
const lockPath = path.join(root, 'package-lock.json');
|
||||
const taroManifest = readJson(taroManifestPath);
|
||||
const lock = readJson(lockPath);
|
||||
|
||||
assert(
|
||||
taroManifest.devDependencies?.[definition.packageName] === definition.packageVersion,
|
||||
`apps/taro/package.json must pin ${definition.packageName}@${definition.packageVersion}`,
|
||||
);
|
||||
assert(
|
||||
taroManifest.scripts?.postinstall === definition.postinstallCommand,
|
||||
'apps/taro postinstall must apply the reviewed Taro H5 runtime patches',
|
||||
);
|
||||
assert(lock.packages?.['apps/taro']?.hasInstallScript === true, 'package-lock.json must record the Taro workspace install hook');
|
||||
assert(
|
||||
lock.packages?.['apps/taro']?.devDependencies?.[definition.packageName] === definition.packageVersion,
|
||||
`package-lock.json must pin the Taro workspace to ${definition.packageName}@${definition.packageVersion}`,
|
||||
);
|
||||
|
||||
const lockEntry = lock.packages?.[`node_modules/${definition.packageName}`];
|
||||
assert(lockEntry?.version === definition.packageVersion, `package-lock.json must resolve ${definition.packageName}@${definition.packageVersion}`);
|
||||
assert(lockEntry?.integrity === definition.lockIntegrity, `package-lock.json has an unexpected ${definition.packageName} integrity`);
|
||||
return taroManifestPath;
|
||||
}
|
||||
|
||||
function resolveInstalledPackage(root, definition, taroManifestPath) {
|
||||
const requireFromTaro = createRequire(taroManifestPath);
|
||||
const installedManifestPath = requireFromTaro.resolve(`${definition.packageName}/package.json`);
|
||||
const installedManifest = readJson(installedManifestPath);
|
||||
assert(
|
||||
installedManifest.version === definition.packageVersion,
|
||||
`installed ${definition.packageName}@${installedManifest.version} does not match the reviewed ${definition.packageVersion}`,
|
||||
);
|
||||
return path.dirname(installedManifestPath);
|
||||
}
|
||||
|
||||
function validatePatchedText(source, patch) {
|
||||
assert(occurrences(source, patch.before) === 0, `patched Taro ${patch.label} still contains the unsafe expression`);
|
||||
assert(occurrences(source, patch.after) === 1, `patched Taro ${patch.label} guard is missing or duplicated`);
|
||||
}
|
||||
|
||||
function planPatch(root, installedPackageRoot, patch, mode) {
|
||||
const targetPath = path.join(installedPackageRoot, patch.targetRelativePath);
|
||||
assert(fs.existsSync(targetPath), `Taro ${patch.label} target is missing: ${targetPath}`);
|
||||
|
||||
const source = fs.readFileSync(targetPath, 'utf8');
|
||||
const installedSha256 = sha256(source);
|
||||
let state = 'patched';
|
||||
let finalSource = source;
|
||||
|
||||
if (installedSha256 === patch.pristineSha256) {
|
||||
state = 'pristine';
|
||||
assert(mode === 'apply', `reviewed Taro ${patch.label} patch is not applied; run npm install or the patch command`);
|
||||
finalSource = replaceExactlyOnce(source, patch.before, patch.after, patch.label);
|
||||
assert(sha256(finalSource) === patch.patchedSha256, `Taro ${patch.label} patch output hash is unexpected`);
|
||||
validatePatchedText(finalSource, patch);
|
||||
state = 'patched-now';
|
||||
} else if (installedSha256 === patch.patchedSha256) {
|
||||
validatePatchedText(source, patch);
|
||||
} else {
|
||||
throw new Error(
|
||||
`unreviewed ${packageContract.packageName} ${patch.label} target hash ${installedSha256}; do not apply the patch to unknown package contents`,
|
||||
);
|
||||
}
|
||||
|
||||
const finalSha256 = sha256(finalSource);
|
||||
assert(finalSha256 === patch.patchedSha256, `installed Taro ${patch.label} patch hash does not match the reviewed result`);
|
||||
return {
|
||||
id: patch.id,
|
||||
state,
|
||||
targetPath,
|
||||
target: path.relative(root, targetPath).replace(/\\/g, '/'),
|
||||
source,
|
||||
finalSource,
|
||||
pristineSha256: patch.pristineSha256,
|
||||
patchedSha256: patch.patchedSha256,
|
||||
installedSha256: finalSha256,
|
||||
};
|
||||
}
|
||||
|
||||
export function enforceTaroH5RuntimePatches({
|
||||
root = scriptRoot,
|
||||
mode = 'check',
|
||||
definition = taroH5RuntimePatchDefinition,
|
||||
} = {}) {
|
||||
assert(mode === 'apply' || mode === 'check', 'mode must be apply or check');
|
||||
const taroManifestPath = validateRepositoryContract(root, definition);
|
||||
const installedPackageRoot = resolveInstalledPackage(root, definition, taroManifestPath);
|
||||
|
||||
// Validate every target before writing either file so an unknown package state fails atomically.
|
||||
const plans = definition.patches.map(patch => planPatch(root, installedPackageRoot, patch, mode));
|
||||
if (mode === 'apply') {
|
||||
for (const plan of plans) {
|
||||
if (plan.finalSource !== plan.source) fs.writeFileSync(plan.targetPath, plan.finalSource, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
status: 'pass',
|
||||
package: definition.packageName,
|
||||
version: definition.packageVersion,
|
||||
patches: Object.fromEntries(plans.map(({ id, state, target, pristineSha256, patchedSha256, installedSha256 }) => [
|
||||
id,
|
||||
{ state, target, pristineSha256, patchedSha256, installedSha256 },
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const apply = argv.includes('--apply');
|
||||
const check = argv.includes('--check');
|
||||
assert(apply !== check, 'pass exactly one of --apply or --check');
|
||||
const result = enforceTaroH5RuntimePatches({ mode: apply ? 'apply' : 'check' });
|
||||
if (argv.includes('--json')) console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
const summary = Object.entries(result.patches)
|
||||
.map(([id, patch]) => `${id}=${patch.state}:${patch.installedSha256}`)
|
||||
.join(', ');
|
||||
console.log(`[PASS] ${result.package}@${result.version} H5 runtime patches (${summary})`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import selfsigned from 'selfsigned';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
|
||||
const outputDir = process.env.TARO_H5_INTERACTION_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||
const smokeApiHostname = 'api-smoke.gongxue100.com';
|
||||
const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], {
|
||||
days: 1,
|
||||
keySize: 2048,
|
||||
algorithm: 'sha256',
|
||||
extensions: [{
|
||||
name: 'subjectAltName',
|
||||
altNames: [{ type: 2, value: smokeApiHostname }],
|
||||
}],
|
||||
});
|
||||
|
||||
const ids = {
|
||||
tenant: '00000000-0000-4000-8000-000000000001',
|
||||
@@ -63,9 +75,18 @@ function parseArgs(argv) {
|
||||
return {
|
||||
json: argv.includes('--json'),
|
||||
keepBrowser: argv.includes('--keep-browser'),
|
||||
serveOnly: argv.includes('--serve-only'),
|
||||
};
|
||||
}
|
||||
|
||||
function waitForShutdownSignal() {
|
||||
return new Promise(resolve => {
|
||||
const shutdown = signal => resolve(signal);
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
});
|
||||
}
|
||||
|
||||
function shanghaiTimestampForFile(date = new Date()) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
@@ -866,31 +887,48 @@ function mockApiPayload(pathname, method, query, body) {
|
||||
return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } };
|
||||
}
|
||||
|
||||
async function createMockApiServer() {
|
||||
async function createMockApiServer(options = {}) {
|
||||
const requests = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
let authenticated = true;
|
||||
const serveOverHttp = options.serveOverHttp === true;
|
||||
const requestHandler = async (req, res) => {
|
||||
const url = new URL(req.url || '/', serveOverHttp ? 'http://127.0.0.1' : `https://${smokeApiHostname}`);
|
||||
if (req.method === 'OPTIONS') {
|
||||
jsonResponse(res, 204, {});
|
||||
return;
|
||||
}
|
||||
const body = await requestBody(req);
|
||||
requests.push({
|
||||
const requestRecord = {
|
||||
method: req.method || 'GET',
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
body,
|
||||
});
|
||||
status: 0,
|
||||
};
|
||||
requests.push(requestRecord);
|
||||
try {
|
||||
if (url.pathname === '/api/auth/me' && !authenticated) {
|
||||
requestRecord.status = 401;
|
||||
jsonResponse(res, 401, { error: 'Invalid or expired session', code: 'AUTH_SESSION_INVALID' });
|
||||
return;
|
||||
}
|
||||
requestRecord.status = 200;
|
||||
jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body));
|
||||
} catch (error) {
|
||||
requestRecord.status = 500;
|
||||
jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
};
|
||||
const server = serveOverHttp
|
||||
? http.createServer(requestHandler)
|
||||
: https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, requestHandler);
|
||||
const port = await listen(server);
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
baseUrl: serveOverHttp ? `http://127.0.0.1:${port}` : `https://${smokeApiHostname}:${port}`,
|
||||
requests,
|
||||
setAuthenticated(value) {
|
||||
authenticated = Boolean(value);
|
||||
},
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
@@ -902,7 +940,7 @@ async function createStaticServer(portal, apiBaseUrl) {
|
||||
apiBaseUrl,
|
||||
supabaseUrl: 'https://auth.example.test',
|
||||
supabasePublishableKey: 'sb_publishable_mock_key_for_h5_interaction_smoke',
|
||||
tenantCode: 'master',
|
||||
tenantCode: '',
|
||||
};
|
||||
|
||||
assertDistExists(portal);
|
||||
@@ -913,6 +951,11 @@ async function createStaticServer(portal, apiBaseUrl) {
|
||||
jsonResponse(res, 200, runtimeConfig);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/favicon.ico') {
|
||||
res.writeHead(204, { 'cache-control': 'public, max-age=86400' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolveStaticPath(distDir, requestPath);
|
||||
if (!filePath) {
|
||||
@@ -956,6 +999,18 @@ function assertDistExists(portal) {
|
||||
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
|
||||
}
|
||||
|
||||
function assertServeOnlyPreviewBuild(portal) {
|
||||
const distDir = path.join(distRoot, portal.dist);
|
||||
const scripts = fs.readdirSync(path.join(distDir, 'js'))
|
||||
.filter(fileName => fileName.endsWith('.js'))
|
||||
.map(fileName => fs.readFileSync(path.join(distDir, 'js', fileName), 'utf8'));
|
||||
if (!scripts.some(source => /["']releaseMode["']\s*:\s*["']preview["']/.test(source))) {
|
||||
throw new Error(
|
||||
`${relative(distDir)} is not a preview build. Run npm run build:taro:h5:preview before npm run serve:taro:h5:qa; rebuild all three production H5 artifacts after visual QA.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
@@ -1018,6 +1073,8 @@ async function startBrowser(options) {
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-h5-interaction-'));
|
||||
const args = [
|
||||
'--headless=new',
|
||||
'--ignore-certificate-errors',
|
||||
`--host-resolver-rules=MAP ${smokeApiHostname} 127.0.0.1`,
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--no-first-run',
|
||||
@@ -1044,8 +1101,10 @@ async function startBrowser(options) {
|
||||
}
|
||||
|
||||
class CdpPage {
|
||||
constructor(wsUrl) {
|
||||
constructor(wsUrl, debugPort, targetId) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.debugPort = debugPort;
|
||||
this.targetId = targetId;
|
||||
this.id = 1;
|
||||
this.pending = new Map();
|
||||
this.events = [];
|
||||
@@ -1113,8 +1172,23 @@ class CdpPage {
|
||||
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close();
|
||||
async setViewport(width, height) {
|
||||
await this.send('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
if (this.targetId) {
|
||||
await fetch(`http://127.0.0.1:${this.debugPort}/json/close/${encodeURIComponent(this.targetId)}`);
|
||||
}
|
||||
} finally {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
diagnosticEvents() {
|
||||
@@ -1128,10 +1202,16 @@ class CdpPage {
|
||||
].includes(event.method))
|
||||
.map(event => {
|
||||
if (event.method === 'Runtime.exceptionThrown') {
|
||||
const details = event.params?.exceptionDetails || {};
|
||||
return {
|
||||
method: event.method,
|
||||
text: event.params?.exceptionDetails?.text,
|
||||
description: event.params?.exceptionDetails?.exception?.description,
|
||||
timestamp: event.params?.timestamp,
|
||||
text: details.text,
|
||||
description: details.exception?.description,
|
||||
url: details.url,
|
||||
lineNumber: details.lineNumber,
|
||||
columnNumber: details.columnNumber,
|
||||
stackTrace: details.stackTrace,
|
||||
};
|
||||
}
|
||||
if (event.method === 'Runtime.consoleAPICalled') {
|
||||
@@ -1170,6 +1250,67 @@ class CdpPage {
|
||||
.slice(-20);
|
||||
}
|
||||
|
||||
runtimeFailures({ allowedHttp = [] } = {}) {
|
||||
const allowedResponse = (status, url) => allowedHttp.some(rule => {
|
||||
const statusMatches = rule.status === undefined || status === undefined || Number(rule.status) === Number(status);
|
||||
const urlMatches = !rule.path || String(url || '').includes(rule.path);
|
||||
return statusMatches && urlMatches;
|
||||
});
|
||||
const failures = [];
|
||||
const requestUrls = new Map(
|
||||
this.events
|
||||
.filter(event => event.method === 'Network.requestWillBeSent')
|
||||
.map(event => [event.params?.requestId, event.params?.request?.url]),
|
||||
);
|
||||
|
||||
for (const event of this.events) {
|
||||
if (event.method === 'Runtime.exceptionThrown') {
|
||||
const details = event.params?.exceptionDetails || {};
|
||||
failures.push({
|
||||
method: event.method,
|
||||
timestamp: event.params?.timestamp,
|
||||
text: details.text,
|
||||
description: details.exception?.description,
|
||||
url: details.url,
|
||||
lineNumber: details.lineNumber,
|
||||
columnNumber: details.columnNumber,
|
||||
stackTrace: details.stackTrace,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.method === 'Runtime.consoleAPICalled' && ['error', 'assert'].includes(event.params?.type)) {
|
||||
failures.push({
|
||||
method: event.method,
|
||||
type: event.params?.type,
|
||||
args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 8),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') {
|
||||
const entry = event.params.entry;
|
||||
if (!allowedResponse(undefined, entry.url)) {
|
||||
failures.push({ method: event.method, level: entry.level, text: entry.text, url: entry.url });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (event.method === 'Network.loadingFailed') {
|
||||
const failure = event.params || {};
|
||||
const url = requestUrls.get(failure.requestId) || '';
|
||||
if (failure.canceled || failure.errorText === 'net::ERR_ABORTED') continue;
|
||||
if (allowedResponse(undefined, url)) continue;
|
||||
failures.push({ method: event.method, errorText: failure.errorText, type: failure.type, url });
|
||||
continue;
|
||||
}
|
||||
if (event.method === 'Network.responseReceived') {
|
||||
const response = event.params?.response || {};
|
||||
if (response.status >= 400 && !allowedResponse(response.status, response.url)) {
|
||||
failures.push({ method: event.method, status: response.status, url: response.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
return failures.slice(-30);
|
||||
}
|
||||
|
||||
async acceptDialogs() {
|
||||
const events = this.events.filter(event => event.method === 'Page.javascriptDialogOpening');
|
||||
this.events = this.events.filter(event => event.method !== 'Page.javascriptDialogOpening');
|
||||
@@ -1180,21 +1321,38 @@ class CdpPage {
|
||||
}
|
||||
|
||||
async function newPage(browser, url) {
|
||||
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
|
||||
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent('about:blank')}`, { method: 'PUT' });
|
||||
if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`);
|
||||
const target = await response.json();
|
||||
return new CdpPage(target.webSocketDebuggerUrl).connect();
|
||||
const page = await new CdpPage(target.webSocketDebuggerUrl, browser.debugPort, target.id).connect();
|
||||
if (url) await page.navigate(url);
|
||||
return page;
|
||||
}
|
||||
|
||||
function assertRuntimeHealthy(page, label, options = {}) {
|
||||
const failures = page.runtimeFailures(options);
|
||||
if (failures.length) {
|
||||
throw new Error(`${label} emitted browser runtime errors:\n${JSON.stringify(failures, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(label, fn, timeoutMs = 10_000) {
|
||||
const started = Date.now();
|
||||
let lastValue;
|
||||
let lastError = '';
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
lastValue = await fn().catch(error => ({ error: error.message }));
|
||||
try {
|
||||
lastValue = await fn();
|
||||
lastError = '';
|
||||
} catch (error) {
|
||||
lastValue = undefined;
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
if (lastValue) return lastValue;
|
||||
await delay(200);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}`);
|
||||
const errorDetail = lastError ? ` Last error: ${lastError}` : '';
|
||||
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}.${errorDetail}`);
|
||||
}
|
||||
|
||||
async function bodyText(page) {
|
||||
@@ -1226,10 +1384,18 @@ async function assertNoText(page, text) {
|
||||
}
|
||||
|
||||
async function waitForPath(page, pathPart, timeoutMs = 10_000) {
|
||||
await waitUntil(`path "${pathPart}"`, async () => {
|
||||
const pathValue = await currentPath(page);
|
||||
return pathValue.includes(pathPart);
|
||||
}, timeoutMs);
|
||||
try {
|
||||
await waitUntil(`path "${pathPart}"`, async () => {
|
||||
const pathValue = await currentPath(page);
|
||||
return pathValue.includes(pathPart);
|
||||
}, timeoutMs);
|
||||
} catch (error) {
|
||||
const [pathValue, textContent] = await Promise.all([
|
||||
currentPath(page).catch(() => ''),
|
||||
bodyText(page).catch(() => ''),
|
||||
]);
|
||||
throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function clickText(page, text) {
|
||||
@@ -1283,16 +1449,12 @@ async function clickText(page, text) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
|
||||
})()
|
||||
`);
|
||||
await page.acceptDialogs();
|
||||
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
|
||||
await delay(350);
|
||||
await page.acceptDialogs();
|
||||
return result;
|
||||
@@ -1351,16 +1513,12 @@ async function clickTextInSection(page, sectionTitle, text) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
|
||||
})()
|
||||
`);
|
||||
await page.acceptDialogs();
|
||||
if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
|
||||
await delay(350);
|
||||
await page.acceptDialogs();
|
||||
return result;
|
||||
@@ -1439,15 +1597,11 @@ async function clickVisibleTextCandidate(page, texts) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, text: textOf(target).slice(0, 120), x, y };
|
||||
})()
|
||||
`);
|
||||
if (result?.ok) {
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
|
||||
await delay(250);
|
||||
await page.acceptDialogs();
|
||||
}
|
||||
@@ -1701,6 +1855,35 @@ async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000)
|
||||
|
||||
async function runStudentJourney(browser, portal, api) {
|
||||
const checks = [];
|
||||
const assertStudentRuntimeHealthy = label => assertRuntimeHealthy(page, label, {
|
||||
allowedHttp: [
|
||||
{ path: 'https://assets.example.test/' },
|
||||
{ path: 'https://pay.example.test/' },
|
||||
],
|
||||
});
|
||||
api.setAuthenticated(false);
|
||||
const authRequestStart = api.requests.length;
|
||||
const loginPage = await newPage(browser, `${portal.baseUrl}/pages/student/login/index`);
|
||||
try {
|
||||
await waitForText(loginPage, '欢迎回来');
|
||||
await waitForText(loginPage, '手机号登录');
|
||||
await waitForText(loginPage, '发送验证码');
|
||||
await waitUntil('unauthenticated /api/auth/me response', async () => {
|
||||
return api.requests
|
||||
.slice(authRequestStart)
|
||||
.some(item => item.path === '/api/auth/me' && item.method === 'GET' && item.status === 401);
|
||||
});
|
||||
assertRuntimeHealthy(loginPage, 'Student login flow', {
|
||||
allowedHttp: [{ status: 401, path: '/api/auth/me' }],
|
||||
});
|
||||
checks.push({ id: 'student.login.unauthenticated_401', status: 'pass', detail: '登录页已真实收到 /api/auth/me 401' });
|
||||
} finally {
|
||||
try {
|
||||
await loginPage.close();
|
||||
} finally {
|
||||
api.setAuthenticated(true);
|
||||
}
|
||||
}
|
||||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||
try {
|
||||
await waitForText(page, '今日学习');
|
||||
@@ -1743,6 +1926,7 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await clickText(page, '刷新状态');
|
||||
await waitForApiRequest(api, '/api/commerce/orders/status', 'GET');
|
||||
checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' });
|
||||
assertStudentRuntimeHealthy('Student H5 journey through checkout');
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本');
|
||||
await waitForText(page, '开始复习');
|
||||
@@ -1759,6 +1943,7 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await clickText(page, '认识');
|
||||
await waitForApiRequest(api, '/api/learning/vocabulary/review', 'POST');
|
||||
checks.push({ id: 'student.vocabulary.review', status: 'pass', detail: '背单词计划和复习提交 API 已触发' });
|
||||
assertStudentRuntimeHealthy('Student H5 journey through vocabulary');
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/handbook/index', '知识手册');
|
||||
await waitForText(page, '高等数学手册');
|
||||
@@ -1775,6 +1960,7 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await waitForText(page, '确认下载');
|
||||
await waitForApiRequest(api, '/api/catalog/assets/download', 'GET');
|
||||
checks.push({ id: 'student.assets.signed_watermark', status: 'pass', detail: '资料预览/下载短签名和水印面板可用' });
|
||||
assertStudentRuntimeHealthy('Student H5 journey through signed assets');
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, `/pages/student/video/index?questionId=${ids.question}`, '视频解析');
|
||||
await waitForText(page, '本题视频解析');
|
||||
@@ -1791,6 +1977,7 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await waitForText(page, '推荐结果');
|
||||
await waitForText(page, '天津职业大学');
|
||||
checks.push({ id: 'student.ai_school.rendered', status: 'pass', detail: 'AI 择校报告列表和推荐结果可渲染' });
|
||||
assertStudentRuntimeHealthy('Student H5 journey through scoreline and AI school');
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/notifications/index', '消息中心');
|
||||
await waitForText(page, '入门勋章已发放');
|
||||
@@ -1798,9 +1985,10 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await waitForApiRequest(api, '/api/profile/notifications/status', 'POST');
|
||||
checks.push({ id: 'student.notifications.status', status: 'pass', detail: '消息筛选和批量已读 API 已触发' });
|
||||
|
||||
assertStudentRuntimeHealthy('Student H5 journey');
|
||||
return checks;
|
||||
} finally {
|
||||
page.close();
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1928,9 +2116,10 @@ async function runTenantJourney(browser, portal, api) {
|
||||
throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||||
}
|
||||
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' });
|
||||
assertRuntimeHealthy(page, 'Tenant admin H5 journey');
|
||||
return checks;
|
||||
} finally {
|
||||
page.close();
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2016,9 +2205,109 @@ async function runPlatformJourney(browser, portal, api) {
|
||||
await clickTextAndConfirmForApi(page, api, '保存员工', '/api/platform-admin/staff', 'PUT');
|
||||
await clickTextAndConfirmForApi(page, api, '禁用', '/api/platform-admin/staff/status', 'PATCH');
|
||||
checks.push({ id: 'platform.staff.operations', status: 'pass', detail: '平台员工保存和禁用 API 已触发' });
|
||||
assertRuntimeHealthy(page, 'Platform admin H5 journey');
|
||||
return checks;
|
||||
} finally {
|
||||
page.close();
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runCrossPortalRuntimeProbe(browser, staticServers) {
|
||||
const student = staticServers.find(item => item.portal === 'student');
|
||||
const tenant = staticServers.find(item => item.portal === 'tenant-admin');
|
||||
const platform = staticServers.find(item => item.portal === 'platform-admin');
|
||||
const page = await newPage(browser, `${student.baseUrl}${student.landingPath}`);
|
||||
try {
|
||||
await waitForText(page, '今日学习');
|
||||
await navigateAndExpect(page, tenant.baseUrl, tenant.landingPath, '工学题库商户后台');
|
||||
await page.setViewport(390, 844);
|
||||
await navigateAndExpect(page, platform.baseUrl, platform.landingPath, 'SaaS 平台后台');
|
||||
assertRuntimeHealthy(page, 'Cross-portal desktop-to-mobile H5 probe');
|
||||
return { status: 'pass', portals: ['student', 'tenant-admin', 'platform-admin'], mobileViewport: '390x844' };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTaroInputWatcherRaceProbe(browser, portal) {
|
||||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||
try {
|
||||
await waitForText(page, 'SaaS 平台后台');
|
||||
const result = await page.evaluate(`(async () => {
|
||||
await customElements.whenDefined('taro-input-core');
|
||||
const element = document.createElement('taro-input-core');
|
||||
element.className = 'runtime-input-watcher-race-probe';
|
||||
element.value = 'before-mount';
|
||||
document.body.appendChild(element);
|
||||
if (typeof element.componentOnReady === 'function') await element.componentOnReady();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const input = element.querySelector('input');
|
||||
const beforeMountValue = input?.value || '';
|
||||
element.value = 'after-mount';
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
const afterMountValue = input?.value || '';
|
||||
element.remove();
|
||||
return { beforeMountValue, afterMountValue };
|
||||
})()`);
|
||||
if (result?.beforeMountValue !== 'before-mount' || result?.afterMountValue !== 'after-mount') {
|
||||
throw new Error(`Taro Input watcher race probe did not synchronize values: ${JSON.stringify(result)}`);
|
||||
}
|
||||
assertRuntimeHealthy(page, 'Taro Input watcher pre-mount race probe');
|
||||
return { status: 'pass', ...result };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTaroButtonLoadingRaceProbe(browser, portal) {
|
||||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||
try {
|
||||
await waitForText(page, 'SaaS 平台后台');
|
||||
const result = await page.evaluate(`(async () => {
|
||||
await customElements.whenDefined('taro-button-core');
|
||||
const element = document.createElement('taro-button-core');
|
||||
element.className = 'runtime-button-loading-race-probe';
|
||||
element.textContent = '运行时按钮探针';
|
||||
document.body.appendChild(element);
|
||||
if (typeof element.componentOnReady === 'function') await element.componentOnReady();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const loadingNode = element.querySelector('.weui-loading');
|
||||
const initialChildCount = element.children.length;
|
||||
const initialDisplay = loadingNode ? getComputedStyle(loadingNode).display : '';
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
element.loading = index % 2 === 0;
|
||||
}
|
||||
element.loading = true;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const activeNode = element.querySelector('.weui-loading');
|
||||
const activeDisplay = activeNode ? getComputedStyle(activeNode).display : '';
|
||||
const activeChildCount = element.children.length;
|
||||
|
||||
element.loading = false;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const inactiveNode = element.querySelector('.weui-loading');
|
||||
const inactiveDisplay = inactiveNode ? getComputedStyle(inactiveNode).display : '';
|
||||
const inactiveChildCount = element.children.length;
|
||||
element.remove();
|
||||
return {
|
||||
loadingNodeStable: Boolean(loadingNode && loadingNode === activeNode && activeNode === inactiveNode),
|
||||
childCounts: [initialChildCount, activeChildCount, inactiveChildCount],
|
||||
displays: { initial: initialDisplay, active: activeDisplay, inactive: inactiveDisplay },
|
||||
};
|
||||
})()`);
|
||||
if (
|
||||
!result?.loadingNodeStable
|
||||
|| new Set(result.childCounts || []).size !== 1
|
||||
|| result?.displays?.active === 'none'
|
||||
|| result?.displays?.inactive !== 'none'
|
||||
) {
|
||||
throw new Error(`Taro Button loading race probe did not keep a stable loading node: ${JSON.stringify(result)}`);
|
||||
}
|
||||
assertRuntimeHealthy(page, 'Taro Button loading node race probe');
|
||||
return { status: 'pass', toggles: 200, ...result };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2055,16 +2344,40 @@ function writeReport(payload) {
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const api = await createMockApiServer();
|
||||
const api = await createMockApiServer({ serveOverHttp: options.serveOnly });
|
||||
const staticServers = [];
|
||||
let browser = null;
|
||||
try {
|
||||
if (options.serveOnly) portals.forEach(assertServeOnlyPreviewBuild);
|
||||
for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl));
|
||||
if (options.serveOnly) {
|
||||
const payload = {
|
||||
mockApi: api.baseUrl,
|
||||
portals: staticServers.map(item => ({
|
||||
portal: item.portal,
|
||||
url: `${item.baseUrl}${item.landingPath}`,
|
||||
})),
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
console.log('[smoke] browser QA servers are ready; press Ctrl+C to stop');
|
||||
await waitForShutdownSignal();
|
||||
return;
|
||||
}
|
||||
browser = await startBrowser(options);
|
||||
const checks = [];
|
||||
checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api));
|
||||
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin'), api));
|
||||
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin'), api));
|
||||
const crossPortal = await runCrossPortalRuntimeProbe(browser, staticServers);
|
||||
const inputWatcherRace = await runTaroInputWatcherRaceProbe(
|
||||
browser,
|
||||
staticServers.find(item => item.portal === 'platform-admin'),
|
||||
);
|
||||
const buttonLoadingRace = await runTaroButtonLoadingRaceProbe(
|
||||
browser,
|
||||
staticServers.find(item => item.portal === 'platform-admin'),
|
||||
);
|
||||
const runtimeHealth = { status: 'pass', crossPortal, inputWatcherRace, buttonLoadingRace };
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -2074,6 +2387,7 @@ async function main() {
|
||||
},
|
||||
browser: {
|
||||
executable: browser.executable,
|
||||
runtimeHealth,
|
||||
},
|
||||
staticServers: staticServers.map(item => ({ portal: item.portal, baseUrl: item.baseUrl, landingPath: item.landingPath })),
|
||||
mockApi: {
|
||||
|
||||
@@ -237,8 +237,6 @@ function validateDistArtifact(portal, distName, options, collector) {
|
||||
const textFiles = walkFiles(dir, ['.html', '.js', '.css', '.json', '.txt']).filter(filePath => !filePath.endsWith('.LICENSE.txt'));
|
||||
const artifactViolations = [];
|
||||
for (const filePath of textFiles) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > 5 * 1024 * 1024) continue;
|
||||
const text = readText(filePath);
|
||||
for (const rule of artifactForbiddenPatterns) {
|
||||
if (rule.pattern.test(text)) artifactViolations.push({ file: relative(filePath), rule: rule.id, message: rule.message });
|
||||
|
||||
@@ -48,6 +48,7 @@ assert.equal(loosePayload.schemaVersion, 1);
|
||||
assert.equal(loosePayload.summary.portals, 3);
|
||||
assert.equal(loosePayload.portals.length, 3);
|
||||
assert.ok(loosePayload.portals.every(item => item.buildCommand.startsWith('npm run build:taro:h5:')));
|
||||
assert.ok(loosePayload.portals.filter(item => item.dist.exists).every(item => /^[0-9a-f]{64}$/.test(item.dist.treeSha256)));
|
||||
assert.ok(loosePayload.checks.some(item => item.id === 'build.student.portal_env' && item.status === 'pass'));
|
||||
|
||||
const strictMissingRuntime = run(['--require-runtime-config']);
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { hashArtifactDirectory } from './release-artifact-hash.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const taroRoot = path.join(repoRoot, 'apps', 'taro');
|
||||
@@ -163,6 +164,7 @@ function summarize(checks, portalResults) {
|
||||
portals: portals.length,
|
||||
distReady: portalResults.filter(item => item.dist?.exists && item.dist?.indexHtml).length,
|
||||
runtimeConfigs: portalResults.filter(item => item.runtimeConfig?.exists).length,
|
||||
treeHashes: portalResults.filter(item => /^[0-9a-f]{64}$/.test(item.dist?.treeSha256 || '')).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +293,7 @@ function inspectDist(portal, collector, options) {
|
||||
dir: relative(dir),
|
||||
indexHtml: fs.existsSync(indexPath),
|
||||
indexSha256: '',
|
||||
treeSha256: '',
|
||||
files: 0,
|
||||
totalBytes: 0,
|
||||
assetReferences: 0,
|
||||
@@ -303,9 +306,10 @@ function inspectDist(portal, collector, options) {
|
||||
}
|
||||
|
||||
collector.pass(`dist.${portal.portal}.exists`, 'H5 dist directory exists', { dir: result.dir });
|
||||
const files = walkFiles(dir);
|
||||
result.files = files.length;
|
||||
result.totalBytes = files.reduce((sum, filePath) => sum + fs.statSync(filePath).size, 0);
|
||||
const tree = hashArtifactDirectory(dir);
|
||||
result.files = tree.files;
|
||||
result.totalBytes = tree.totalBytes;
|
||||
result.treeSha256 = tree.sha256;
|
||||
|
||||
if (!result.indexHtml) {
|
||||
if (options.requireDist) collector.fail(`dist.${portal.portal}.index`, 'H5 index.html is missing', { file: relative(indexPath) });
|
||||
@@ -322,6 +326,7 @@ function inspectDist(portal, collector, options) {
|
||||
collector.pass(`dist.${portal.portal}.index`, 'H5 index.html is deployable', {
|
||||
file: relative(indexPath),
|
||||
sha256: result.indexSha256,
|
||||
treeSha256: result.treeSha256,
|
||||
assetReferences: result.assetReferences,
|
||||
});
|
||||
}
|
||||
@@ -375,7 +380,7 @@ function printHuman(manifest) {
|
||||
console.log(`Taro H5 release manifest: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
||||
for (const portal of manifest.portals) {
|
||||
console.log(`[${portal.portal}] ${portal.buildCommand}`);
|
||||
console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`);
|
||||
console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} tree=${portal.dist.treeSha256 || 'missing'} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`);
|
||||
console.log(` landing=${portal.landingPath}`);
|
||||
}
|
||||
for (const item of manifest.checks.filter(check => check.status !== 'pass')) {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import selfsigned from 'selfsigned';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
|
||||
const smokeApiHostname = 'api-smoke.gongxue100.com';
|
||||
const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], {
|
||||
days: 1,
|
||||
keySize: 2048,
|
||||
algorithm: 'sha256',
|
||||
extensions: [{
|
||||
name: 'subjectAltName',
|
||||
altNames: [{ type: 2, value: smokeApiHostname }],
|
||||
}],
|
||||
});
|
||||
|
||||
const portals = [
|
||||
{
|
||||
@@ -98,6 +110,35 @@ function textResponse(response, statusCode, body, headers = {}) {
|
||||
}
|
||||
|
||||
async function request(input, options = {}) {
|
||||
const target = new URL(input);
|
||||
if (target.protocol === 'https:' && target.hostname === smokeApiHostname) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request({
|
||||
protocol: 'https:',
|
||||
hostname: '127.0.0.1',
|
||||
port: target.port,
|
||||
path: `${target.pathname}${target.search}`,
|
||||
method: options.method || 'GET',
|
||||
headers: { host: target.host, ...(options.headers || {}) },
|
||||
servername: smokeApiHostname,
|
||||
rejectUnauthorized: false,
|
||||
}, response => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', chunk => {
|
||||
text += chunk;
|
||||
});
|
||||
response.on('end', () => resolve({
|
||||
ok: response.statusCode >= 200 && response.statusCode < 300,
|
||||
status: response.statusCode || 0,
|
||||
headers: response.headers,
|
||||
text,
|
||||
}));
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
const response = await fetch(input, {
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
@@ -113,8 +154,8 @@ async function request(input, options = {}) {
|
||||
|
||||
async function createMockApiServer() {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const server = https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, (req, res) => {
|
||||
const url = new URL(req.url || '/', `https://${smokeApiHostname}`);
|
||||
requests.push({
|
||||
method: req.method,
|
||||
path: url.pathname,
|
||||
@@ -159,7 +200,7 @@ async function createMockApiServer() {
|
||||
|
||||
const port = await listen(server);
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
baseUrl: `https://${smokeApiHostname}:${port}`,
|
||||
requests,
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
@@ -172,7 +213,7 @@ async function createStaticServer(portal, apiBaseUrl) {
|
||||
apiBaseUrl,
|
||||
supabaseUrl: 'https://auth.example.test',
|
||||
supabasePublishableKey: 'sb_publishable_mock_key_for_static_smoke',
|
||||
tenantCode: 'master',
|
||||
tenantCode: '',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
@@ -259,9 +300,10 @@ function validateRuntimeConfig(config, portal) {
|
||||
if (unknownKeys.length) errors.push(`unknown runtime config keys: ${unknownKeys.join(', ')}`);
|
||||
if (forbiddenKeys.length) errors.push(`forbidden runtime config keys: ${forbiddenKeys.join(', ')}`);
|
||||
if (config.portal !== portal.portal) errors.push(`portal mismatch: expected ${portal.portal}, got ${config.portal}`);
|
||||
if (!String(config.apiBaseUrl || '').startsWith('http://127.0.0.1:')) errors.push('apiBaseUrl must point to the local mock API in smoke');
|
||||
if (!String(config.apiBaseUrl || '').startsWith(`https://${smokeApiHostname}:`)) errors.push('apiBaseUrl must point to the HTTPS mock API host in smoke');
|
||||
if (!String(config.supabaseUrl || '').startsWith('https://')) errors.push('supabaseUrl must be HTTPS even in smoke runtime config');
|
||||
if (!config.supabasePublishableKey) errors.push('supabasePublishableKey is required');
|
||||
if (String(config.tenantCode || '').trim()) errors.push('production H5 tenantCode must be empty and resolved from the browser origin');
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (forbiddenValuePatterns.some(pattern => pattern.test(String(value)))) errors.push(`secret-looking value in ${key}`);
|
||||
}
|
||||
@@ -326,8 +368,8 @@ async function smokePortal(portal, api) {
|
||||
|
||||
if (runtimeConfig) {
|
||||
const resolveUrl = new URL('/api/tenant/resolve', runtimeConfig.apiBaseUrl);
|
||||
resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode);
|
||||
resolveUrl.searchParams.set('host', new URL(staticServer.baseUrl).host);
|
||||
if (runtimeConfig.tenantCode) resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode);
|
||||
const tenantResolve = await request(resolveUrl, { headers: { 'x-smoke-portal': portal.portal } });
|
||||
let tenantPayload = null;
|
||||
try {
|
||||
@@ -337,8 +379,12 @@ async function smokePortal(portal, api) {
|
||||
}
|
||||
checks.push({
|
||||
id: `${portal.portal}.tenant_resolve_contract`,
|
||||
ok: tenantResolve.ok && Boolean(tenantPayload?.item?.tenantId) && tenantPayload.item.features?.enableLeaderboard === false,
|
||||
detail: `status=${tenantResolve.status}`,
|
||||
ok: tenantResolve.ok
|
||||
&& Boolean(tenantPayload?.item?.tenantId)
|
||||
&& tenantPayload.item.features?.enableLeaderboard === false
|
||||
&& Boolean(api.requests.at(-1)?.query?.host)
|
||||
&& !Object.prototype.hasOwnProperty.call(api.requests.at(-1)?.query || {}, 'tenantCode'),
|
||||
detail: `status=${tenantResolve.status} query=${JSON.stringify(api.requests.at(-1)?.query || {})}`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
|
||||
@@ -11,7 +12,7 @@ const contracts = [
|
||||
file: 'pages/student/home/index.tsx',
|
||||
mustContain: [
|
||||
'loadStudentDashboard',
|
||||
'loadCurrentUser',
|
||||
'useApp',
|
||||
'/pages/student/catalog/index',
|
||||
'/pages/student/review/index',
|
||||
'/pages/student/profile/index',
|
||||
@@ -75,10 +76,20 @@ const contracts = [
|
||||
'createOrder',
|
||||
'createPayment',
|
||||
'loadOrderStatus',
|
||||
'Taro.requestPayment',
|
||||
'launchPayment',
|
||||
'/pages/student/order-detail/index?orderNo=',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cross-platform.payment-adapter',
|
||||
file: 'capabilities/payment.ts',
|
||||
mustContain: [
|
||||
'isWeappRuntime',
|
||||
'Taro.requestPayment',
|
||||
'openExternalUrl',
|
||||
'copyText',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'student.profile-center',
|
||||
file: 'pages/student/profile/index.tsx',
|
||||
@@ -103,8 +114,8 @@ const contracts = [
|
||||
id: 'tenant.workbench-permission-modules',
|
||||
file: 'pages/tenant-admin/workbench/index.tsx',
|
||||
mustContain: [
|
||||
'loadTenantPermissions',
|
||||
'canOpenModule',
|
||||
'useApp',
|
||||
'canTenantMenu',
|
||||
'/pages/tenant-admin/dashboard/index',
|
||||
'/pages/tenant-admin/students/index',
|
||||
'/pages/tenant-admin/content/index',
|
||||
@@ -299,6 +310,7 @@ const contracts = [
|
||||
const routeContracts = [
|
||||
{
|
||||
id: 'student-first-web-launch',
|
||||
portal: 'student',
|
||||
routes: [
|
||||
'pages/student/home/index',
|
||||
'pages/student/catalog/index',
|
||||
@@ -312,6 +324,7 @@ const routeContracts = [
|
||||
},
|
||||
{
|
||||
id: 'tenant-admin-launch',
|
||||
portal: 'tenant-admin',
|
||||
routes: [
|
||||
'pages/tenant-admin/workbench/index',
|
||||
'pages/tenant-admin/dashboard/index',
|
||||
@@ -324,6 +337,7 @@ const routeContracts = [
|
||||
},
|
||||
{
|
||||
id: 'platform-admin-launch',
|
||||
portal: 'platform-admin',
|
||||
routes: [
|
||||
'pages/platform-admin/workbench/index',
|
||||
'pages/platform-admin/tenants/index',
|
||||
@@ -346,15 +360,29 @@ function assertNotContains(text, token, label) {
|
||||
assert.ok(!text.includes(token), `${label} must not contain ${token}`);
|
||||
}
|
||||
|
||||
function parseAppRoutes() {
|
||||
const text = readText('app.config.ts');
|
||||
const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m);
|
||||
assert.ok(match, 'apps/taro/src/app.config.ts must define pages');
|
||||
return new Set([...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map(item => item[1]));
|
||||
async function loadPortalRoutes(portal) {
|
||||
process.env.TARO_APP_PORTAL = portal;
|
||||
process.env.TARO_ENV = 'h5';
|
||||
globalThis.defineAppConfig = value => value;
|
||||
const moduleUrl = pathToFileURL(path.join(taroSrc, 'app.config.ts'));
|
||||
moduleUrl.searchParams.set('persona', portal);
|
||||
return new Set(((await import(moduleUrl.href)).default.pages || []));
|
||||
}
|
||||
|
||||
const appRoutes = parseAppRoutes();
|
||||
const originalPortal = process.env.TARO_APP_PORTAL;
|
||||
const originalTaroEnv = process.env.TARO_ENV;
|
||||
const routesByPortal = {
|
||||
student: await loadPortalRoutes('student'),
|
||||
'tenant-admin': await loadPortalRoutes('tenant-admin'),
|
||||
'platform-admin': await loadPortalRoutes('platform-admin'),
|
||||
};
|
||||
if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL;
|
||||
else process.env.TARO_APP_PORTAL = originalPortal;
|
||||
if (originalTaroEnv === undefined) delete process.env.TARO_ENV;
|
||||
else process.env.TARO_ENV = originalTaroEnv;
|
||||
|
||||
for (const contract of routeContracts) {
|
||||
const appRoutes = routesByPortal[contract.portal];
|
||||
for (const route of contract.routes) {
|
||||
assert.ok(appRoutes.has(route), `${contract.id} route must be registered: ${route}`);
|
||||
assert.ok(fs.existsSync(path.join(taroSrc, `${route}.tsx`)), `${contract.id} route file must exist: ${route}.tsx`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
|
||||
@@ -33,15 +34,6 @@ function walkIndexPages(dir) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseAppRoutes() {
|
||||
const text = readText(appConfigPath);
|
||||
const match = text.match(/const\s+allPageRoutes\s*=\s*\[([\s\S]*?)\]/m);
|
||||
assert.ok(match, 'apps/taro/src/app.config.ts must define const allPageRoutes = [...]');
|
||||
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)]
|
||||
.map(item => item[1].trim())
|
||||
.filter(route => route.startsWith('pages/'));
|
||||
}
|
||||
|
||||
function parsePortalLandingRoutes() {
|
||||
const text = readText(appConfigPath);
|
||||
const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\}/m);
|
||||
@@ -66,11 +58,69 @@ function uniqueSorted(items) {
|
||||
return [...new Set(items)].sort();
|
||||
}
|
||||
|
||||
const appRoutes = parseAppRoutes();
|
||||
const appRouteSet = new Set(appRoutes);
|
||||
async function loadAppConfig(portal, taroEnv) {
|
||||
process.env.TARO_APP_PORTAL = portal;
|
||||
process.env.TARO_ENV = taroEnv;
|
||||
globalThis.defineAppConfig = value => value;
|
||||
const moduleUrl = pathToFileURL(appConfigPath);
|
||||
moduleUrl.searchParams.set('portal', portal);
|
||||
moduleUrl.searchParams.set('env', taroEnv);
|
||||
return (await import(moduleUrl.href)).default;
|
||||
}
|
||||
|
||||
function expandedRoutes(config) {
|
||||
const mainRoutes = config.pages || [];
|
||||
const packageRoutes = (config.subPackages || config.subpackages || []).flatMap(item => (
|
||||
(item.pages || []).map(route => `${String(item.root || '').replace(/\/$/, '')}/${String(route).replace(/^\//, '')}`)
|
||||
));
|
||||
return [...mainRoutes, ...packageRoutes];
|
||||
}
|
||||
|
||||
const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile);
|
||||
const actualRouteSet = new Set(actualRoutes);
|
||||
const portalLandingRouteMap = parsePortalLandingRoutes();
|
||||
const originalPortal = process.env.TARO_APP_PORTAL;
|
||||
const originalTaroEnv = process.env.TARO_ENV;
|
||||
const portalConfigs = {
|
||||
student: await loadAppConfig('student', 'h5'),
|
||||
'tenant-admin': await loadAppConfig('tenant-admin', 'h5'),
|
||||
'platform-admin': await loadAppConfig('platform-admin', 'h5'),
|
||||
};
|
||||
const studentWeappConfig = await loadAppConfig('student', 'weapp');
|
||||
if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL;
|
||||
else process.env.TARO_APP_PORTAL = originalPortal;
|
||||
if (originalTaroEnv === undefined) delete process.env.TARO_ENV;
|
||||
else process.env.TARO_ENV = originalTaroEnv;
|
||||
|
||||
const sharedH5Routes = ['pages/bootstrap/index', 'pages/student/login/index'];
|
||||
const expectedRoutesByPortal = {
|
||||
student: actualRoutes.filter(route => route === 'pages/bootstrap/index' || route.startsWith('pages/student/')),
|
||||
'tenant-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/tenant-admin/')),
|
||||
'platform-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/platform-admin/')),
|
||||
};
|
||||
|
||||
for (const [portal, config] of Object.entries(portalConfigs)) {
|
||||
const routes = config.pages || [];
|
||||
assert.equal(routes.length, new Set(routes).size, `${portal} H5 config must not contain duplicate page routes`);
|
||||
assert.equal(routes[0], portalLandingRouteMap[portal], `${portal} H5 landing route must be the first page`);
|
||||
assert.deepEqual(
|
||||
uniqueSorted(routes),
|
||||
uniqueSorted(expectedRoutesByPortal[portal]),
|
||||
`${portal} H5 build must package only its own portal pages plus shared bootstrap/login pages`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.deepEqual(studentWeappConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must contain only the bootstrap page');
|
||||
assert.equal(studentWeappConfig.subPackages?.length, 1, 'Student WeApp must use one stable student subpackage');
|
||||
assert.equal(studentWeappConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp subpackage root must preserve existing student routes');
|
||||
assert.deepEqual(
|
||||
uniqueSorted(expandedRoutes(studentWeappConfig)),
|
||||
uniqueSorted(expectedRoutesByPortal.student),
|
||||
'Student WeApp main package and subpackage must cover every student route without admin pages',
|
||||
);
|
||||
|
||||
const appRoutes = uniqueSorted(Object.values(portalConfigs).flatMap(config => config.pages || []));
|
||||
const appRouteSet = new Set(appRoutes);
|
||||
|
||||
assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes');
|
||||
|
||||
@@ -127,4 +177,4 @@ for (const route of uniqueSorted(handoffRoutes)) {
|
||||
assert.ok(actualRouteSet.has(route), `Frontend handoff doc references a page file that is missing: ${route}`);
|
||||
}
|
||||
|
||||
console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages)`);
|
||||
console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages; H5 portals cropped; WeApp student subpackage verified)`);
|
||||
|
||||
@@ -2,7 +2,27 @@ import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
globalThis.__TARO_PUBLIC_BUILD_CONFIG__ = {
|
||||
portal: 'student',
|
||||
target: 'weapp',
|
||||
releaseMode: 'production',
|
||||
weappTenantMode: 'fixed',
|
||||
apiBaseUrl: 'https://compiled-api.gongxue100.com',
|
||||
supabaseUrl: 'https://compiled-auth.gongxue100.com',
|
||||
supabasePublishableKey: 'sb_publishable_compiled_key',
|
||||
tenantCode: 'compiled-tenant',
|
||||
};
|
||||
const envModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`).href);
|
||||
delete globalThis.__TARO_PUBLIC_BUILD_CONFIG__;
|
||||
|
||||
assert.equal(envModule.appEnv.portal, 'student');
|
||||
assert.equal(envModule.appEnv.apiBaseUrl, 'https://compiled-api.gongxue100.com');
|
||||
assert.equal(envModule.appEnv.supabaseUrl, 'https://compiled-auth.gongxue100.com');
|
||||
assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_compiled_key');
|
||||
assert.equal(envModule.appEnv.tenantCode, 'compiled-tenant');
|
||||
assert.equal(envModule.taroRuntimeEnv(), 'weapp');
|
||||
assert.equal(envModule.isWeappRuntime(), true);
|
||||
assert.equal(envModule.taroWeappTenantMode(), 'fixed');
|
||||
|
||||
envModule.applyRuntimeConfig({
|
||||
portal: 'tenant-admin',
|
||||
@@ -18,6 +38,9 @@ assert.equal(envModule.appEnv.supabaseUrl, 'https://auth.gongxue100.com');
|
||||
assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_public_key');
|
||||
assert.equal(envModule.appEnv.tenantCode, 'tenant-a');
|
||||
|
||||
envModule.applyRuntimeConfig({ tenantCode: '' });
|
||||
assert.equal(envModule.appEnv.tenantCode, '', 'An empty runtime tenantCode must clear a compiled tenant fallback');
|
||||
|
||||
envModule.applyRuntimeConfig({
|
||||
TARO_APP_PORTAL: 'platform-admin',
|
||||
TARO_APP_API_BASE_URL: 'https://api2.gongxue100.com',
|
||||
@@ -50,4 +73,100 @@ assert.throws(
|
||||
/Unknown key in runtime config: unexpectedFeatureFlag/,
|
||||
);
|
||||
|
||||
globalThis.__TARO_PUBLIC_BUILD_CONFIG__ = {
|
||||
portal: 'student',
|
||||
target: 'h5',
|
||||
releaseMode: 'production',
|
||||
weappTenantMode: '',
|
||||
apiBaseUrl: '',
|
||||
supabaseUrl: '',
|
||||
supabasePublishableKey: '',
|
||||
tenantCode: '',
|
||||
};
|
||||
globalThis.window = {
|
||||
location: { origin: 'https://student.example.test' },
|
||||
fetch: async () => {
|
||||
throw new Error('network unavailable');
|
||||
},
|
||||
};
|
||||
const productionH5Url = pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`);
|
||||
productionH5Url.searchParams.set('runtime-config-test', 'production-h5');
|
||||
const productionH5Env = await import(productionH5Url.href);
|
||||
assert.equal(productionH5Env.appEnv.apiBaseUrl, '', 'Production H5 must not compile a local API fallback');
|
||||
assert.equal(productionH5Env.taroReleaseMode(), 'production');
|
||||
await assert.rejects(
|
||||
() => productionH5Env.loadRuntimeConfig(),
|
||||
/Production H5 runtime-config\.json request failed: network unavailable/,
|
||||
'Production H5 must fail closed when runtime config cannot be fetched',
|
||||
);
|
||||
globalThis.window.fetch = async () => ({ ok: false, status: 404 });
|
||||
await assert.rejects(
|
||||
() => productionH5Env.loadRuntimeConfig(),
|
||||
/Production H5 runtime-config\.json request failed with status 404/,
|
||||
'Production H5 must fail closed when runtime config is missing',
|
||||
);
|
||||
globalThis.window.fetch = async () => ({ ok: true, status: 200, text: async () => '' });
|
||||
await assert.rejects(
|
||||
() => productionH5Env.loadRuntimeConfig(),
|
||||
/Production H5 runtime-config\.json is empty or unreadable/,
|
||||
'Production H5 must fail closed when runtime config is unreadable',
|
||||
);
|
||||
globalThis.window.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ portal: 'student', apiBaseUrl: '' }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => productionH5Env.loadRuntimeConfig(),
|
||||
/Production H5 runtime-config\.json apiBaseUrl must be an absolute HTTPS URL and must not use localhost or loopback/,
|
||||
'Production H5 must fail closed when runtime config omits its API endpoint',
|
||||
);
|
||||
for (const apiBaseUrl of [
|
||||
'http://api.gongxue100.com',
|
||||
'https://localhost:8787',
|
||||
'https://127.0.0.1:8787',
|
||||
'https://[::1]:8787',
|
||||
]) {
|
||||
globalThis.window.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ portal: 'student', apiBaseUrl }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => productionH5Env.loadRuntimeConfig(),
|
||||
/must be an absolute HTTPS URL and must not use localhost or loopback/,
|
||||
`Production H5 must reject unsafe API endpoint ${apiBaseUrl}`,
|
||||
);
|
||||
}
|
||||
globalThis.window.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
portal: 'student',
|
||||
apiBaseUrl: 'https://api.gongxue100.com/',
|
||||
supabaseUrl: 'https://auth.gongxue100.com/',
|
||||
supabasePublishableKey: 'sb_publishable_runtime_test',
|
||||
tenantCode: '',
|
||||
}),
|
||||
});
|
||||
await productionH5Env.loadRuntimeConfig();
|
||||
assert.equal(productionH5Env.appEnv.apiBaseUrl, 'https://api.gongxue100.com');
|
||||
assert.equal(productionH5Env.appEnv.tenantCode, '', 'Production H5 must remain domain resolved');
|
||||
globalThis.window.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
portal: 'student',
|
||||
apiBaseUrl: 'https://api.gongxue100.com',
|
||||
tenantCode: 'campus-north',
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => productionH5Env.loadRuntimeConfig(),
|
||||
/tenantCode must be empty; tenant is resolved from the browser origin/,
|
||||
'Production H5 must reject a fixed tenant override',
|
||||
);
|
||||
delete globalThis.window;
|
||||
delete globalThis.__TARO_PUBLIC_BUILD_CONFIG__;
|
||||
|
||||
console.log('[PASS] Taro runtime config guardrails');
|
||||
|
||||
139
scripts/taro-supply-chain-audit-test.js
Normal file
139
scripts/taro-supply-chain-audit-test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
allowedInvalidEdges,
|
||||
npmAuditArgs,
|
||||
securedBundleDependencies,
|
||||
validateAuditPayload,
|
||||
validateNpmLsPayload,
|
||||
} from './taro-supply-chain-audit.js';
|
||||
import {
|
||||
enforceTaroH5RuntimePatches,
|
||||
taroButtonLoadingPatch,
|
||||
taroH5RuntimePatchDefinition,
|
||||
taroInputWatcherPatch,
|
||||
} from './taro-components-h5-runtime-patch.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
const packageLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'));
|
||||
const taroPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'apps', 'taro', 'package.json'), 'utf8'));
|
||||
|
||||
assert.equal(taroPackage.devDependencies?.['@tarojs/components'], taroH5RuntimePatchDefinition.packageVersion);
|
||||
assert.equal(taroPackage.scripts?.postinstall, taroH5RuntimePatchDefinition.postinstallCommand);
|
||||
assert.equal(packageLock.packages?.['apps/taro']?.hasInstallScript, true);
|
||||
const patchState = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' });
|
||||
assert.equal(patchState.status, 'pass');
|
||||
assert.equal(patchState.patches.inputWatcher.installedSha256, taroInputWatcherPatch.patchedSha256);
|
||||
assert.equal(patchState.patches.buttonLoading.installedSha256, taroButtonLoadingPatch.patchedSha256);
|
||||
|
||||
for (const [name, expected] of Object.entries(securedBundleDependencies)) {
|
||||
assert.equal(packageJson.overrides?.[name], expected.version, `${name} override must stay exact`);
|
||||
assert.equal(packageLock.packages?.[`node_modules/${name}`]?.version, expected.version, `${name} lock version must stay exact`);
|
||||
assert.equal(packageLock.packages?.[`node_modules/${name}`]?.integrity, expected.integrity, `${name} lock integrity must stay exact`);
|
||||
}
|
||||
|
||||
const dependencyNode = (name, parents) => ({
|
||||
version: securedBundleDependencies[name].version,
|
||||
invalid: parents.map(parent => `"${parent.declared}" from node_modules/${parent.parent}`).join(', '),
|
||||
problems: [`invalid: ${name}@${securedBundleDependencies[name].version} /repo/node_modules/${name}`],
|
||||
});
|
||||
|
||||
const edgesFor = dependency => allowedInvalidEdges.filter(edge => edge.dependency === dependency);
|
||||
const validLs = {
|
||||
error: { code: 'ELSPROBLEMS' },
|
||||
problems: Object.entries(securedBundleDependencies).map(([name, expected]) => `invalid: ${name}@${expected.version} /repo/node_modules/${name}`),
|
||||
dependencies: {
|
||||
'@tiku-saas/taro': {
|
||||
dependencies: {
|
||||
'@tarojs/components': {
|
||||
dependencies: {
|
||||
swiper: dependencyNode('swiper', edgesFor('swiper').filter(edge => edge.parent === '@tarojs/components')),
|
||||
},
|
||||
},
|
||||
'@tarojs/plugin-platform-h5': {
|
||||
dependencies: {
|
||||
'@tarojs/components-react': {
|
||||
dependencies: {
|
||||
swiper: dependencyNode('swiper', edgesFor('swiper').filter(edge => edge.parent === '@tarojs/components-react')),
|
||||
},
|
||||
},
|
||||
'@tarojs/taro-h5': {
|
||||
dependencies: {
|
||||
'lodash-es': dependencyNode('lodash-es', edgesFor('lodash-es').filter(edge => edge.parent === '@tarojs/taro-h5')),
|
||||
},
|
||||
},
|
||||
'lodash-es': dependencyNode('lodash-es', edgesFor('lodash-es').filter(edge => edge.parent === '@tarojs/plugin-platform-h5')),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lsSummary = validateNpmLsPayload(validLs);
|
||||
assert.equal(lsSummary.edges.length, 4);
|
||||
|
||||
assert.throws(
|
||||
() => validateNpmLsPayload({ ...validLs, problems: [...validLs.problems, 'extraneous: unsafe@1.0.0 /repo/node_modules/unsafe'] }),
|
||||
/unexpected problem|unapproved problem/,
|
||||
'new npm ls problems must fail closed',
|
||||
);
|
||||
|
||||
const missingEdgeLs = structuredClone(validLs);
|
||||
delete missingEdgeLs.dependencies['@tiku-saas/taro'].dependencies['@tarojs/plugin-platform-h5'].dependencies['@tarojs/components-react'];
|
||||
assert.throws(() => validateNpmLsPayload(missingEdgeLs), /once per reviewed invalid edge|invalid-edge set changed/, 'the exception set must not silently shrink or change');
|
||||
|
||||
const auditFixture = {
|
||||
vulnerabilities: {
|
||||
'@tarojs/cli': { severity: 'high', isDirect: true },
|
||||
download: { severity: 'critical', isDirect: false, via: [] },
|
||||
'git-clone': { severity: 'high', isDirect: false, via: [{ source: 1093404, severity: 'high' }] },
|
||||
esbuild: { severity: 'moderate', isDirect: false },
|
||||
},
|
||||
metadata: {
|
||||
vulnerabilities: { info: 0, low: 0, moderate: 1, high: 2, critical: 1, total: 4 },
|
||||
},
|
||||
};
|
||||
|
||||
const auditSummary = validateAuditPayload(auditFixture);
|
||||
assert.equal(auditSummary.reviewedHighCritical.length, 3);
|
||||
assert.deepEqual(auditSummary.reviewedAdvisories, [1093404]);
|
||||
|
||||
assert.throws(
|
||||
() => validateAuditPayload({
|
||||
...auditFixture,
|
||||
vulnerabilities: { ...auditFixture.vulnerabilities, swiper: { severity: 'critical', isDirect: false } },
|
||||
}),
|
||||
/still reports swiper/,
|
||||
'bundle dependency advisories must fail the gate',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => validateAuditPayload({
|
||||
...auditFixture,
|
||||
vulnerabilities: { ...auditFixture.vulnerabilities, 'new-build-risk': { severity: 'high', isDirect: false } },
|
||||
}),
|
||||
/new unreviewed high/,
|
||||
'new high or critical toolchain findings must require review',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => validateAuditPayload({
|
||||
...auditFixture,
|
||||
vulnerabilities: {
|
||||
...auditFixture.vulnerabilities,
|
||||
'git-clone': { severity: 'high', isDirect: false, via: [{ source: 9999999, severity: 'high' }] },
|
||||
},
|
||||
}),
|
||||
/new unreviewed high Taro advisory/,
|
||||
'new advisories on an already allowlisted package must require review',
|
||||
);
|
||||
|
||||
const auditArgs = npmAuditArgs('https://registry.npmjs.org/');
|
||||
assert.ok(auditArgs.includes('--workspace'));
|
||||
assert.ok(auditArgs.includes('@tiku-saas/taro'));
|
||||
assert.equal(auditArgs.some(arg => arg === '--omit=dev' || arg.startsWith('--omit=')), false, 'Taro audit must include dependencies that are marked dev but bundled into H5');
|
||||
|
||||
console.log('[PASS] Taro supply-chain audit contract');
|
||||
316
scripts/taro-supply-chain-audit.js
Normal file
316
scripts/taro-supply-chain-audit.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { enforceTaroH5RuntimePatches } from './taro-components-h5-runtime-patch.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
export const securedBundleDependencies = Object.freeze({
|
||||
'lodash-es': Object.freeze({
|
||||
version: '4.18.1',
|
||||
integrity: 'sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==',
|
||||
}),
|
||||
swiper: Object.freeze({
|
||||
version: '12.1.2',
|
||||
integrity: 'sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==',
|
||||
}),
|
||||
});
|
||||
|
||||
export const allowedInvalidEdges = Object.freeze([
|
||||
Object.freeze({ parent: '@tarojs/components', dependency: 'swiper', declared: '11.1.15' }),
|
||||
Object.freeze({ parent: '@tarojs/components-react', dependency: 'swiper', declared: '11.1.15' }),
|
||||
Object.freeze({ parent: '@tarojs/plugin-platform-h5', dependency: 'lodash-es', declared: '4.17.21' }),
|
||||
Object.freeze({ parent: '@tarojs/taro-h5', dependency: 'lodash-es', declared: '4.17.21' }),
|
||||
]);
|
||||
|
||||
export const reviewedHighCriticalToolchainPackages = new Set([
|
||||
'@tarojs/cli',
|
||||
'@tarojs/plugin-doctor',
|
||||
'@tarojs/webpack5-runner',
|
||||
'cacheable-request',
|
||||
'decompress',
|
||||
'download',
|
||||
'download-git-repo',
|
||||
'git-clone',
|
||||
'glob',
|
||||
'got',
|
||||
'html-minifier',
|
||||
'http-cache-semantics',
|
||||
'serialize-javascript',
|
||||
]);
|
||||
|
||||
export const reviewedHighCriticalAdvisories = new Set([
|
||||
1093404,
|
||||
1102456,
|
||||
1105440,
|
||||
1109842,
|
||||
1113686,
|
||||
1122670,
|
||||
]);
|
||||
|
||||
export const npmLsArgs = Object.freeze([
|
||||
'ls',
|
||||
...Object.keys(securedBundleDependencies),
|
||||
'--workspace',
|
||||
'@tiku-saas/taro',
|
||||
'--all',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
export function npmAuditArgs(registry = process.env.NPM_AUDIT_REGISTRY || 'https://registry.npmjs.org/') {
|
||||
return [
|
||||
'audit',
|
||||
`--registry=${registry}`,
|
||||
'--workspace',
|
||||
'@tiku-saas/taro',
|
||||
'--audit-level=high',
|
||||
'--json',
|
||||
];
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function dependencyProblem(problem) {
|
||||
const match = /^invalid: ((?:@[^/\s]+\/)?[^@\s]+)@([^\s]+)\s+(.+)$/.exec(String(problem || ''));
|
||||
return match ? { name: match[1], version: match[2], location: match[3] } : null;
|
||||
}
|
||||
|
||||
function expectedProblemKey(name) {
|
||||
return `${name}@${securedBundleDependencies[name].version}`;
|
||||
}
|
||||
|
||||
function collectTreeProblems(node, dependencyName = '', state = { problems: [], invalidEdges: [] }) {
|
||||
if (node?.error?.code === 'ELSPROBLEMS' && !state.errorCode) state.errorCode = node.error.code;
|
||||
for (const problem of node?.problems || []) state.problems.push(String(problem));
|
||||
|
||||
if (node?.invalid) {
|
||||
assert(securedBundleDependencies[dependencyName], `npm ls reported an unexpected invalid dependency: ${dependencyName}`);
|
||||
const edgePattern = /"([^"]+)" from node_modules\/((?:@[^/,]+\/)?[^,\s]+)/g;
|
||||
let match;
|
||||
let matched = false;
|
||||
while ((match = edgePattern.exec(String(node.invalid)))) {
|
||||
matched = true;
|
||||
state.invalidEdges.push({ parent: match[2], dependency: dependencyName, declared: match[1] });
|
||||
}
|
||||
assert(matched, `npm ls returned an unparseable invalid edge for ${dependencyName}: ${node.invalid}`);
|
||||
}
|
||||
|
||||
assert(!node?.extraneous, `npm ls reported an extraneous dependency: ${dependencyName}`);
|
||||
assert(!node?.missing, `npm ls reported a missing dependency: ${dependencyName}`);
|
||||
|
||||
for (const [name, dependency] of Object.entries(node?.dependencies || {})) {
|
||||
collectTreeProblems(dependency, name, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function validateNpmLsPayload(payload) {
|
||||
assert(payload && typeof payload === 'object', 'npm ls did not return a JSON object');
|
||||
assert(payload.error?.code === 'ELSPROBLEMS', 'npm ls must fail only with the reviewed Taro exact-dependency ELSPROBLEMS result');
|
||||
|
||||
const state = collectTreeProblems(payload);
|
||||
const problemCounts = new Map();
|
||||
const actualProblemKeys = new Set();
|
||||
for (const problem of state.problems) {
|
||||
const parsed = dependencyProblem(problem);
|
||||
assert(parsed, `npm ls returned an unexpected problem: ${problem}`);
|
||||
assert(securedBundleDependencies[parsed.name], `npm ls returned an unapproved problem: ${problem}`);
|
||||
assert(parsed.version === securedBundleDependencies[parsed.name].version, `npm ls problem uses an unexpected ${parsed.name} version: ${problem}`);
|
||||
assert(parsed.location.replace(/\\/g, '/').endsWith(`/node_modules/${parsed.name}`), `npm ls problem has an unexpected location: ${problem}`);
|
||||
const key = `${parsed.name}@${parsed.version}`;
|
||||
actualProblemKeys.add(key);
|
||||
problemCounts.set(key, (problemCounts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
const expectedProblemKeys = new Set(Object.keys(securedBundleDependencies).map(expectedProblemKey));
|
||||
assert(actualProblemKeys.size === expectedProblemKeys.size, 'npm ls must report exactly the two reviewed invalid packages');
|
||||
for (const key of expectedProblemKeys) {
|
||||
assert(actualProblemKeys.has(key), `npm ls did not report the reviewed invalid package ${key}`);
|
||||
const dependency = key.slice(0, key.lastIndexOf('@'));
|
||||
const expectedCount = 1 + allowedInvalidEdges.filter(edge => edge.dependency === dependency).length;
|
||||
assert(problemCounts.get(key) === expectedCount, `npm ls must report ${key} once at the root and once per reviewed invalid edge`);
|
||||
}
|
||||
|
||||
const actualEdges = new Set(state.invalidEdges.map(edge => `${edge.parent}>${edge.dependency}@${edge.declared}`));
|
||||
const expectedEdges = new Set(allowedInvalidEdges.map(edge => `${edge.parent}>${edge.dependency}@${edge.declared}`));
|
||||
assert(actualEdges.size === expectedEdges.size, `npm ls invalid-edge set changed: expected ${expectedEdges.size}, received ${actualEdges.size}`);
|
||||
for (const edge of expectedEdges) assert(actualEdges.has(edge), `npm ls did not report the reviewed invalid edge ${edge}`);
|
||||
|
||||
return {
|
||||
packages: [...expectedProblemKeys].sort(),
|
||||
edges: allowedInvalidEdges.map(edge => ({ ...edge })),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAuditPayload(payload) {
|
||||
assert(payload && typeof payload === 'object', 'npm audit did not return a JSON object');
|
||||
assert(payload.metadata?.vulnerabilities, 'npm audit payload is missing vulnerability metadata');
|
||||
|
||||
const vulnerabilities = payload.vulnerabilities || {};
|
||||
for (const dependency of Object.keys(securedBundleDependencies)) {
|
||||
assert(!vulnerabilities[dependency], `npm audit still reports ${dependency}; the bundle mitigation is not effective`);
|
||||
}
|
||||
|
||||
const reviewed = [];
|
||||
const moderate = [];
|
||||
const observedAdvisories = new Set();
|
||||
for (const [name, vulnerability] of Object.entries(vulnerabilities)) {
|
||||
const severity = String(vulnerability.severity || '').toLowerCase();
|
||||
if (severity === 'high' || severity === 'critical') {
|
||||
assert(reviewedHighCriticalToolchainPackages.has(name), `new unreviewed ${severity} Taro vulnerability: ${name}`);
|
||||
for (const via of vulnerability.via || []) {
|
||||
if (!via || typeof via !== 'object') continue;
|
||||
const viaSeverity = String(via.severity || '').toLowerCase();
|
||||
if (viaSeverity !== 'high' && viaSeverity !== 'critical') continue;
|
||||
assert(reviewedHighCriticalAdvisories.has(via.source), `new unreviewed ${viaSeverity} Taro advisory ${via.source} in ${name}`);
|
||||
observedAdvisories.add(via.source);
|
||||
}
|
||||
reviewed.push({ name, severity, direct: Boolean(vulnerability.isDirect) });
|
||||
} else {
|
||||
moderate.push({ name, severity, direct: Boolean(vulnerability.isDirect) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counts: { ...payload.metadata.vulnerabilities },
|
||||
reviewedAdvisories: [...observedAdvisories].sort((left, right) => left - right),
|
||||
reviewedHighCritical: reviewed.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
other: moderate.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
};
|
||||
}
|
||||
|
||||
function findPackageJsonFromEntry(entryPath, expectedName) {
|
||||
let current = path.dirname(entryPath);
|
||||
while (current !== path.dirname(current)) {
|
||||
const candidate = path.join(current, 'package.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
const manifest = readJson(candidate);
|
||||
if (manifest.name === expectedName) return candidate;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
throw new Error(`cannot locate package.json for ${expectedName}`);
|
||||
}
|
||||
|
||||
function resolveDependencyPackage(parentPackagePath, dependency) {
|
||||
const requireFromParent = createRequire(parentPackagePath);
|
||||
try {
|
||||
return requireFromParent.resolve(`${dependency}/package.json`);
|
||||
} catch {
|
||||
return findPackageJsonFromEntry(requireFromParent.resolve(dependency), dependency);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateManifestLockAndInstall(root = repoRoot) {
|
||||
const rootManifest = readJson(path.join(root, 'package.json'));
|
||||
const lock = readJson(path.join(root, 'package-lock.json'));
|
||||
const installed = {};
|
||||
|
||||
for (const [dependency, expected] of Object.entries(securedBundleDependencies)) {
|
||||
assert(rootManifest.overrides?.[dependency] === expected.version, `package.json must override ${dependency} to ${expected.version}`);
|
||||
const lockEntry = lock.packages?.[`node_modules/${dependency}`];
|
||||
assert(lockEntry?.version === expected.version, `package-lock.json must resolve ${dependency} to ${expected.version}`);
|
||||
assert(lockEntry?.integrity === expected.integrity, `package-lock.json has an unexpected integrity for ${dependency}`);
|
||||
|
||||
const installedManifest = readJson(path.join(root, 'node_modules', dependency, 'package.json'));
|
||||
assert(installedManifest.version === expected.version, `node_modules has ${dependency}@${installedManifest.version}; run npm ci to install the secured lock`);
|
||||
installed[dependency] = installedManifest.version;
|
||||
}
|
||||
|
||||
for (const edge of allowedInvalidEdges) {
|
||||
const parentPackagePath = path.join(root, 'node_modules', ...edge.parent.split('/'), 'package.json');
|
||||
assert(fs.existsSync(parentPackagePath), `reviewed Taro parent package is not installed: ${edge.parent}`);
|
||||
const parentManifest = readJson(parentPackagePath);
|
||||
assert(parentManifest.dependencies?.[edge.dependency] === edge.declared, `${edge.parent} no longer declares the reviewed ${edge.dependency}@${edge.declared} edge; remove or update the exception`);
|
||||
const resolvedPackagePath = resolveDependencyPackage(parentPackagePath, edge.dependency);
|
||||
const resolvedManifest = readJson(resolvedPackagePath);
|
||||
assert(resolvedManifest.version === securedBundleDependencies[edge.dependency].version, `${edge.parent} resolves ${edge.dependency}@${resolvedManifest.version} instead of the secured override`);
|
||||
}
|
||||
|
||||
return installed;
|
||||
}
|
||||
|
||||
function runNpm(args) {
|
||||
return spawnSync(npmCommand, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function parseCommandJson(result, label) {
|
||||
try {
|
||||
return JSON.parse(result.stdout || '{}');
|
||||
} catch (error) {
|
||||
throw new Error(`${label} did not return valid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runSupplyChainAudit() {
|
||||
const versions = validateManifestLockAndInstall();
|
||||
const h5RuntimePatches = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' });
|
||||
|
||||
const lsResult = runNpm(npmLsArgs);
|
||||
assert(lsResult.status === 1, `npm ls returned ${lsResult.status}; expected the reviewed ELSPROBLEMS status`);
|
||||
const invalid = validateNpmLsPayload(parseCommandJson(lsResult, 'npm ls'));
|
||||
|
||||
const auditResult = runNpm(npmAuditArgs());
|
||||
assert(auditResult.status === 0 || auditResult.status === 1, `npm audit failed operationally with status ${auditResult.status}: ${auditResult.stderr}`);
|
||||
const audit = validateAuditPayload(parseCommandJson(auditResult, 'npm audit'));
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: 'pass-with-reviewed-toolchain-risk',
|
||||
securedBundleDependencies: versions,
|
||||
h5RuntimePatches,
|
||||
npmLs: invalid,
|
||||
audit,
|
||||
riskBoundary: {
|
||||
appliesTo: 'Taro 4.2.0 CLI and build toolchain; swiper and lodash-es bundle advisories are mitigated',
|
||||
controls: [
|
||||
'build only on isolated trusted runners',
|
||||
'do not expose Taro development servers to public networks',
|
||||
'do not process untrusted templates, archives, repositories, or CLI arguments',
|
||||
'publish only reviewed apps/taro/dist static artifacts',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function printHuman(result) {
|
||||
console.log('[PASS] Taro supply-chain override and install state');
|
||||
console.log(`[PASS] bundle dependencies: ${Object.entries(result.securedBundleDependencies).map(([name, version]) => `${name}@${version}`).join(', ')}`);
|
||||
const patchSummary = Object.entries(result.h5RuntimePatches.patches)
|
||||
.map(([id, patch]) => `${id}=${patch.installedSha256}`)
|
||||
.join(', ');
|
||||
console.log(`[PASS] Taro H5 runtime patches: ${result.h5RuntimePatches.version} ${patchSummary}`);
|
||||
console.log(`[PASS] npm ls exceptions restricted to ${result.npmLs.edges.length} reviewed Taro exact-dependency edges`);
|
||||
console.log(`[WARN] full Taro workspace audit remains ${result.audit.counts.total} findings (${result.audit.counts.critical} critical, ${result.audit.counts.high} high, ${result.audit.counts.moderate} moderate)`);
|
||||
console.log('[WARN] remaining high/critical findings are confined to the reviewed Taro CLI/build-toolchain package allowlist');
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
const json = process.argv.includes('--json');
|
||||
try {
|
||||
const result = runSupplyChainAudit();
|
||||
if (json) console.log(JSON.stringify(result, null, 2));
|
||||
else printHuman(result);
|
||||
} catch (error) {
|
||||
if (json) console.log(JSON.stringify({ schemaVersion: 1, status: 'fail', error: error.message }, null, 2));
|
||||
else console.error(`[FAIL] ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
221
scripts/taro-weapp-release-guardrails.js
Normal file
221
scripts/taro-weapp-release-guardrails.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
validateProductionApiBaseUrl,
|
||||
validateProductionTenantCode,
|
||||
validateProductionWechatAppId,
|
||||
validateTenantCodeFormat,
|
||||
} from './build-weapp-student.js';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
|
||||
const defaultDistRoot = path.join(repoRoot, 'apps', 'taro', 'dist', 'weapp-student');
|
||||
|
||||
function walkFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? walkFiles(filePath) : [filePath];
|
||||
});
|
||||
}
|
||||
|
||||
function directoryBytes(dir) {
|
||||
return walkFiles(dir).reduce((total, filePath) => total + fs.statSync(filePath).size, 0);
|
||||
}
|
||||
|
||||
function parsedJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
export function extractCompiledPublicBuildConfig(source) {
|
||||
const normalizedSource = source.replace(/"([A-Za-z][A-Za-z0-9]*)":/g, '$1:');
|
||||
const marker = 'weappTenantMode:';
|
||||
const configs = [];
|
||||
let markerIndex = normalizedSource.indexOf(marker);
|
||||
while (markerIndex >= 0) {
|
||||
const start = normalizedSource.lastIndexOf('{portal:', markerIndex);
|
||||
if (start >= 0) {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let depth = 0;
|
||||
for (let index = start; index < normalizedSource.length; index += 1) {
|
||||
const char = normalizedSource[index];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === '\\') escaped = true;
|
||||
else if (char === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = true;
|
||||
else if (char === '{') depth += 1;
|
||||
else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const candidate = normalizedSource.slice(start, index + 1);
|
||||
const keys = ['portal', 'target', 'releaseMode', 'weappTenantMode', 'apiBaseUrl', 'supabaseUrl', 'supabasePublishableKey', 'tenantCode'];
|
||||
const config = Object.fromEntries(keys.map(key => {
|
||||
const match = candidate.match(new RegExp(`(?:^|[,\\{])${key}:"((?:\\\\.|[^"\\\\])*)"`));
|
||||
if (!match) return [key, ''];
|
||||
return [key, JSON.parse(`"${match[1]}"`)];
|
||||
}));
|
||||
if (config.portal && config.target && config.weappTenantMode) configs.push(config);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
markerIndex = normalizedSource.indexOf(marker, markerIndex + marker.length);
|
||||
}
|
||||
|
||||
const unique = [...new Map(configs.map(config => [JSON.stringify(config), config])).values()];
|
||||
if (!unique.length) throw new Error('Compiled __TARO_PUBLIC_BUILD_CONFIG__ was not found in common.js');
|
||||
if (unique.length > 1) throw new Error('Multiple conflicting public build configs were found in common.js');
|
||||
return unique[0];
|
||||
}
|
||||
|
||||
function validationError(validate, value) {
|
||||
try {
|
||||
validate(value);
|
||||
return '';
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectWeappRelease({ distRoot = defaultDistRoot, requireProduction = false } = {}) {
|
||||
const checks = [];
|
||||
const push = (status, id, message, details = {}) => checks.push({ status, id, message, details });
|
||||
|
||||
if (!fs.existsSync(distRoot)) {
|
||||
push('fail', 'weapp.dist.exists', 'Student WeApp output is missing', { dist: path.relative(repoRoot, distRoot) });
|
||||
return checks;
|
||||
}
|
||||
|
||||
push('pass', 'weapp.dist.exists', 'Student WeApp output exists');
|
||||
const projectConfigPath = path.join(distRoot, 'project.config.json');
|
||||
const appConfigPath = path.join(distRoot, 'app.json');
|
||||
const commonPath = path.join(distRoot, 'common.js');
|
||||
if (![projectConfigPath, appConfigPath, commonPath].every(fs.existsSync)) {
|
||||
push('fail', 'weapp.artifacts.required', 'WeApp output is missing project.config.json, app.json, or common.js');
|
||||
return checks;
|
||||
}
|
||||
|
||||
const projectConfig = parsedJson(projectConfigPath);
|
||||
const appConfig = parsedJson(appConfigPath);
|
||||
const appId = String(projectConfig.appid || '');
|
||||
const appIdError = validationError(validateProductionWechatAppId, appId);
|
||||
if (requireProduction && appIdError) push('fail', 'weapp.appid', appIdError);
|
||||
else push(appIdError ? 'warn' : 'pass', 'weapp.appid', appIdError || 'WeApp AppID is production-ready');
|
||||
|
||||
if (requireProduction && projectConfig.setting?.urlCheck !== true) push('fail', 'weapp.url_check', 'Production WeApp must enable legal-domain URL checks');
|
||||
else push(projectConfig.setting?.urlCheck === true ? 'pass' : 'warn', 'weapp.url_check', projectConfig.setting?.urlCheck === true ? 'WeApp legal-domain URL checks are enabled' : 'WeApp URL checks are disabled for preview');
|
||||
|
||||
let compiledConfig = null;
|
||||
try {
|
||||
compiledConfig = extractCompiledPublicBuildConfig(fs.readFileSync(commonPath, 'utf8'));
|
||||
push('pass', 'weapp.public_config.found', 'Compiled public build config was found in common.js');
|
||||
} catch (error) {
|
||||
push('fail', 'weapp.public_config.found', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
if (compiledConfig) {
|
||||
if (compiledConfig.portal === 'student' && compiledConfig.target === 'weapp') {
|
||||
push('pass', 'weapp.public_config.identity', 'Compiled public build config targets the student WeApp');
|
||||
} else {
|
||||
push('fail', 'weapp.public_config.identity', 'Compiled public build config must target portal=student and target=weapp', { compiledConfig });
|
||||
}
|
||||
|
||||
if (requireProduction && compiledConfig.releaseMode !== 'production') {
|
||||
push('fail', 'weapp.public_config.release_mode', 'Production guard requires releaseMode=production', { releaseMode: compiledConfig.releaseMode });
|
||||
} else {
|
||||
push(compiledConfig.releaseMode === 'production' ? 'pass' : 'warn', 'weapp.public_config.release_mode', `Compiled release mode is ${compiledConfig.releaseMode || 'missing'}`);
|
||||
}
|
||||
|
||||
const tenantMode = String(compiledConfig.weappTenantMode || '');
|
||||
if (tenantMode !== 'fixed' && tenantMode !== 'launch') {
|
||||
push('fail', 'weapp.public_config.tenant_mode', 'Compiled weappTenantMode must be fixed or launch');
|
||||
} else {
|
||||
push('pass', 'weapp.public_config.tenant_mode', `Compiled WeApp tenant mode is ${tenantMode}`);
|
||||
const tenantCode = String(compiledConfig.tenantCode || '');
|
||||
if (tenantMode === 'launch') {
|
||||
if (tenantCode) push('fail', 'weapp.public_config.tenant_code', 'Launch mode must not retain a compiled tenant fallback', { tenantCode });
|
||||
else push('pass', 'weapp.public_config.tenant_code', 'Launch mode contains no compiled tenant fallback');
|
||||
} else {
|
||||
const tenantError = validationError(requireProduction ? validateProductionTenantCode : validateTenantCodeFormat, tenantCode);
|
||||
if (tenantError) push('fail', 'weapp.public_config.tenant_code', tenantError);
|
||||
else push('pass', 'weapp.public_config.tenant_code', 'Fixed mode contains a valid compiled tenant code');
|
||||
}
|
||||
}
|
||||
|
||||
const apiBaseUrl = String(compiledConfig.apiBaseUrl || '');
|
||||
const apiError = validationError(validateProductionApiBaseUrl, apiBaseUrl);
|
||||
if (requireProduction && apiError) push('fail', 'weapp.public_config.api', apiError);
|
||||
else push(apiError ? 'warn' : 'pass', 'weapp.public_config.api', apiError || 'Compiled API endpoint is production-ready');
|
||||
}
|
||||
|
||||
const mainPages = Array.isArray(appConfig.pages) ? appConfig.pages : [];
|
||||
const subpackages = appConfig.subPackages || appConfig.subpackages || [];
|
||||
if (mainPages.length === 1 && mainPages[0] === 'pages/bootstrap/index') push('pass', 'weapp.main_pages', 'WeApp main package contains only bootstrap');
|
||||
else push('fail', 'weapp.main_pages', 'WeApp main package must contain only bootstrap', { mainPages });
|
||||
if (subpackages.length === 1 && subpackages[0]?.root === 'pages/student') push('pass', 'weapp.subpackage', 'Student pages use one subpackage');
|
||||
else push('fail', 'weapp.subpackage', 'Student WeApp subpackage contract is invalid');
|
||||
|
||||
const totalBytes = directoryBytes(distRoot);
|
||||
const subpackageBytes = directoryBytes(path.join(distRoot, 'pages', 'student'));
|
||||
const mainBytes = totalBytes - subpackageBytes;
|
||||
if (totalBytes > 4 * 1024 * 1024) push('fail', 'weapp.size.total', 'WeApp output exceeds the 4 MiB release budget', { totalBytes });
|
||||
else push('pass', 'weapp.size.total', 'WeApp output is within the 4 MiB release budget', { totalBytes });
|
||||
if (mainBytes > 2 * 1024 * 1024) push('fail', 'weapp.size.main', 'WeApp main package exceeds the 2 MiB release budget', { mainBytes });
|
||||
else push('pass', 'weapp.size.main', 'WeApp main package is within the 2 MiB release budget', { mainBytes });
|
||||
|
||||
const forbidden = [
|
||||
['database-url', /postgres(?:ql)?:\/\//i],
|
||||
['service-role', /\b(?:service_role|sb_secret_)\b/i],
|
||||
['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/i],
|
||||
];
|
||||
const violations = [];
|
||||
const localEndpointFiles = [];
|
||||
const allowedDependencyLocalEndpoints = [];
|
||||
for (const filePath of walkFiles(distRoot).filter(item => /\.(?:js|json|wxml|wxss|txt)$/.test(item))) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const relativeFile = path.relative(repoRoot, filePath);
|
||||
const localEndpoints = source.match(/https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/gi) || [];
|
||||
for (const endpoint of localEndpoints) {
|
||||
if (relativeFile === 'apps/taro/dist/weapp-student/vendors.js' && endpoint.toLowerCase() === 'http://localhost:9999') {
|
||||
allowedDependencyLocalEndpoints.push({ file: relativeFile, endpoint, dependency: '@supabase/auth-js' });
|
||||
} else {
|
||||
localEndpointFiles.push(relativeFile);
|
||||
}
|
||||
}
|
||||
for (const [id, pattern] of forbidden) {
|
||||
if (pattern.test(source)) violations.push({ id, file: relativeFile });
|
||||
}
|
||||
}
|
||||
if (allowedDependencyLocalEndpoints.length) {
|
||||
push('pass', 'weapp.dependency_local_placeholder', 'Known inert Supabase SDK local placeholder is explicitly allowlisted', { matches: allowedDependencyLocalEndpoints });
|
||||
}
|
||||
if (localEndpointFiles.length) push(requireProduction ? 'fail' : 'warn', 'weapp.local_endpoints', 'WeApp output contains local API endpoints', { files: [...new Set(localEndpointFiles)].slice(0, 20) });
|
||||
else push('pass', 'weapp.local_endpoints', 'WeApp output contains no local API endpoints');
|
||||
if (violations.length) push('fail', 'weapp.forbidden_patterns', 'WeApp output contains secret-looking values', { violations: violations.slice(0, 20) });
|
||||
else push('pass', 'weapp.forbidden_patterns', 'WeApp output contains no secret-looking values');
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function runGuardrails({ argv = process.argv.slice(2), distRoot = defaultDistRoot } = {}) {
|
||||
const requireProduction = argv.includes('--production');
|
||||
const json = argv.includes('--json');
|
||||
const checks = inspectWeappRelease({ distRoot, requireProduction });
|
||||
const summary = checks.reduce((result, item) => ({ ...result, [item.status]: result[item.status] + 1 }), { fail: 0, warn: 0, pass: 0 });
|
||||
const payload = { summary, checks };
|
||||
if (json) console.log(JSON.stringify(payload, null, 2));
|
||||
else {
|
||||
console.log(`Taro WeApp release guardrails: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
||||
checks.filter(item => item.status !== 'pass').forEach(item => console.log(`[${item.status.toUpperCase()}] ${item.id}: ${item.message}`));
|
||||
}
|
||||
return summary.fail > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
||||
process.exitCode = runGuardrails();
|
||||
}
|
||||
80
scripts/tenant-foreign-key-audit-test.js
Normal file
80
scripts/tenant-foreign-key-audit-test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT,
|
||||
EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256,
|
||||
buildTenantForeignKeyViolationQuery,
|
||||
tenantForeignKeyExceptions,
|
||||
tenantForeignKeySchemaSha256,
|
||||
} from './lib/tenant-foreign-key-audit.js';
|
||||
import {
|
||||
assertTenantForeignKeyAuditTarget,
|
||||
parseTenantForeignKeyAuditOptions,
|
||||
} from './tenant-foreign-key-audit.js';
|
||||
|
||||
const relation = overrides => ({
|
||||
childTable: 'child_rows',
|
||||
constraintName: 'child_rows_parent_id_fkey',
|
||||
childColumns: ['parent_id'],
|
||||
parentTable: 'parent_rows',
|
||||
parentColumns: ['id'],
|
||||
validated: true,
|
||||
updateAction: 'a',
|
||||
deleteAction: 'a',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
assert.equal(EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT, 189);
|
||||
assert.match(EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(tenantForeignKeyExceptions().length, 3);
|
||||
|
||||
const digestA = tenantForeignKeySchemaSha256([relation({ childTable: 'b' }), relation({ childTable: 'a' })]);
|
||||
const digestB = tenantForeignKeySchemaSha256([relation({ childTable: 'a' }), relation({ childTable: 'b' })]);
|
||||
assert.equal(digestA, digestB, 'schema fingerprint must not depend on catalog row ordering');
|
||||
|
||||
const defaultQuery = buildTenantForeignKeyViolationQuery([relation({})]);
|
||||
assert.match(defaultQuery, /child\.tenant_id is distinct from parent\.tenant_id/i);
|
||||
|
||||
const globalRuleQuery = buildTenantForeignKeyViolationQuery([
|
||||
relation({
|
||||
childTable: 'platform_audit_alerts',
|
||||
constraintName: 'platform_audit_alerts_rule_id_fkey',
|
||||
childColumns: ['rule_id'],
|
||||
parentTable: 'platform_audit_alert_rules',
|
||||
}),
|
||||
]);
|
||||
assert.match(globalRuleQuery, /parent\.tenant_id is not null and child\.tenant_id is distinct from parent\.tenant_id/i);
|
||||
|
||||
const platformBankQuery = buildTenantForeignKeyViolationQuery([
|
||||
relation({
|
||||
childTable: 'tenant_question_bank_adoptions',
|
||||
constraintName: 'tenant_question_bank_adoptions_source_question_bank_id_fkey',
|
||||
childColumns: ['source_question_bank_id'],
|
||||
parentTable: 'question_banks',
|
||||
}),
|
||||
]);
|
||||
assert.match(platformBankQuery, /parent\.source_scope is distinct from 'platform'/i);
|
||||
|
||||
assert.throws(
|
||||
() => parseTenantForeignKeyAuditOptions([], {}),
|
||||
/DATABASE_URL is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertTenantForeignKeyAuditTarget('postgresql://postgres:postgres@127.0.0.1:5432/postgres'),
|
||||
/reserved for tikupro-pg/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertTenantForeignKeyAuditTarget('postgresql://postgres:postgres@tikupro-pg:55432/postgres'),
|
||||
/tikupro-pg targets are forbidden/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
assertTenantForeignKeyAuditTarget('postgresql://postgres:secret@127.0.0.1:55432/postgres'),
|
||||
{ host: '127.0.0.1', port: '55432', database: 'postgres', user: 'postgres' },
|
||||
);
|
||||
|
||||
const readiness = fs.readFileSync('scripts/production-readiness-check.js', 'utf8');
|
||||
assert.match(readiness, /db\.tenant_foreign_keys\.schema/);
|
||||
const launchGate = fs.readFileSync('scripts/production-launch-gate.js', 'utf8');
|
||||
assert.match(launchGate, /db\.tenant-foreign-key-audit/);
|
||||
|
||||
console.log('[PASS] tenant foreign key audit contract');
|
||||
128
scripts/tenant-foreign-key-audit.js
Normal file
128
scripts/tenant-foreign-key-audit.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import pg from 'pg';
|
||||
import {
|
||||
assertDestructiveTestDatabase,
|
||||
describeDatabaseTarget,
|
||||
resolveDestructiveTestConfirmation,
|
||||
} from './lib/destructive-test-database-guard.js';
|
||||
import {
|
||||
TENANT_FOREIGN_KEY_AUDIT_KIND,
|
||||
auditTenantForeignKeyData,
|
||||
loadTenantForeignKeyRelations,
|
||||
summarizeTenantForeignKeySchema,
|
||||
tenantForeignKeyExceptions,
|
||||
} from './lib/tenant-foreign-key-audit.js';
|
||||
|
||||
const { Client } = pg;
|
||||
const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i;
|
||||
|
||||
function argumentValue(argv, name) {
|
||||
const index = argv.indexOf(name);
|
||||
if (index >= 0) return String(argv[index + 1] || '').trim();
|
||||
const prefix = `${name}=`;
|
||||
const item = argv.find(value => value.startsWith(prefix));
|
||||
return item ? item.slice(prefix.length).trim() : '';
|
||||
}
|
||||
|
||||
export function parseTenantForeignKeyAuditOptions(argv = process.argv.slice(2), env = process.env) {
|
||||
const databaseUrl = String(env.DATABASE_URL || '').trim();
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is required');
|
||||
const timeoutValue = argumentValue(argv, '--statement-timeout-ms') || env.TENANT_FK_AUDIT_STATEMENT_TIMEOUT_MS || '120000';
|
||||
const statementTimeoutMs = Number(timeoutValue);
|
||||
if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1_000 || statementTimeoutMs > 900_000) {
|
||||
throw new Error('statement timeout must be an integer between 1000 and 900000 milliseconds');
|
||||
}
|
||||
return {
|
||||
databaseUrl,
|
||||
statementTimeoutMs,
|
||||
confirmation: resolveDestructiveTestConfirmation(env, argv),
|
||||
json: argv.includes('--json'),
|
||||
quiet: argv.includes('--quiet'),
|
||||
writePath: argumentValue(argv, '--write'),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertTenantForeignKeyAuditTarget(databaseUrl) {
|
||||
const target = describeDatabaseTarget(databaseUrl);
|
||||
const host = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
if (['127.0.0.1', 'localhost', '::1'].includes(host) && target.port === '5432') {
|
||||
throw new Error('Refusing tenant foreign key audit: local port 5432 is reserved for tikupro-pg');
|
||||
}
|
||||
if ([target.host, target.database, target.user].some(value => BLOCKED_TARGET_PATTERN.test(value))) {
|
||||
throw new Error('Refusing tenant foreign key audit: tikupro-pg targets are forbidden');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function runTenantForeignKeyAudit(options) {
|
||||
const startedAt = new Date();
|
||||
const target = assertTenantForeignKeyAuditTarget(options.databaseUrl);
|
||||
const client = new Client({
|
||||
connectionString: options.databaseUrl,
|
||||
application_name: 'tiku-tenant-foreign-key-audit',
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const safety = await assertDestructiveTestDatabase({
|
||||
client,
|
||||
databaseUrl: options.databaseUrl,
|
||||
confirmation: options.confirmation,
|
||||
operation: 'tenant foreign key full-data audit on an isolated clone',
|
||||
});
|
||||
const relations = await loadTenantForeignKeyRelations(client);
|
||||
const schema = summarizeTenantForeignKeySchema(relations);
|
||||
const violations = schema.schemaMatches
|
||||
? await auditTenantForeignKeyData(client, relations, options.statementTimeoutMs)
|
||||
: [];
|
||||
const completedAt = new Date();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: TENANT_FOREIGN_KEY_AUDIT_KIND,
|
||||
startedAt: startedAt.toISOString(),
|
||||
completedAt: completedAt.toISOString(),
|
||||
durationMs: completedAt.getTime() - startedAt.getTime(),
|
||||
target,
|
||||
safety: { databaseEnvironment: safety.environment },
|
||||
schema,
|
||||
exceptions: tenantForeignKeyExceptions(),
|
||||
data: {
|
||||
auditedRelations: schema.schemaMatches ? relations.length : 0,
|
||||
invalidRelations: violations.length,
|
||||
violations,
|
||||
},
|
||||
status: schema.schemaMatches && violations.length === 0 ? 'pass' : 'fail',
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options;
|
||||
try {
|
||||
options = parseTenantForeignKeyAuditOptions();
|
||||
const artifact = await runTenantForeignKeyAudit(options);
|
||||
if (options.writePath) {
|
||||
const outputPath = path.resolve(process.cwd(), options.writePath);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
if (options.json) console.log(JSON.stringify(artifact, null, 2));
|
||||
else if (!options.quiet) {
|
||||
console.log(`Tenant foreign key audit: ${artifact.status.toUpperCase()}`);
|
||||
console.log(`Relations: ${artifact.schema.relationCount}; exceptions: ${artifact.schema.exceptionCount}; invalid: ${artifact.data.invalidRelations}`);
|
||||
if (options.writePath) console.log(`Artifact: ${path.resolve(process.cwd(), options.writePath)}`);
|
||||
}
|
||||
if (artifact.status !== 'pass') process.exitCode = 1;
|
||||
} catch (error) {
|
||||
const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) };
|
||||
if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2));
|
||||
else console.error(failure.error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();
|
||||
58
scripts/tenant-permission-resolution-test.js
Normal file
58
scripts/tenant-permission-resolution-test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const {
|
||||
hasResolvedTenantPermission,
|
||||
hasTenantPermission,
|
||||
} = await import('../apps/api/src/features/tenant-admin/auth.ts');
|
||||
|
||||
function auth(role, permissions = {}, templatePermissions = {}) {
|
||||
return { role, permissions, templatePermissions };
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
hasTenantPermission(auth('teacher'), 'content:questions:write'),
|
||||
true,
|
||||
'teacher role defaults should grant content permissions',
|
||||
);
|
||||
assert.equal(
|
||||
hasTenantPermission(auth('teacher', { 'content:*': false }), 'content:questions:write'),
|
||||
false,
|
||||
'member wildcard deny must override role defaults',
|
||||
);
|
||||
assert.equal(
|
||||
hasTenantPermission(auth('tenant_operator', { 'content:questions:write': false }), 'content:questions:write'),
|
||||
false,
|
||||
'member exact deny must override role defaults',
|
||||
);
|
||||
assert.equal(
|
||||
hasTenantPermission(auth('teacher', { 'content:questions:write': true, 'content:*': false }), 'content:questions:write'),
|
||||
true,
|
||||
'member exact allow must take precedence over a broader member deny',
|
||||
);
|
||||
assert.equal(
|
||||
hasTenantPermission(auth('teacher', {}, { 'content:*': false }), 'content:questions:write'),
|
||||
false,
|
||||
'template wildcard deny must override role defaults',
|
||||
);
|
||||
assert.equal(
|
||||
hasTenantPermission(auth('teacher', { 'content:*': false }, { 'content:questions:write': true }), 'content:questions:write'),
|
||||
false,
|
||||
'member decisions must take precedence over template decisions',
|
||||
);
|
||||
assert.equal(
|
||||
hasResolvedTenantPermission(auth('teacher'), 'content:analytics:read', false),
|
||||
false,
|
||||
'a caller may narrow role defaults for sensitive content permissions',
|
||||
);
|
||||
assert.equal(
|
||||
hasResolvedTenantPermission(auth('teacher', { 'content:analytics:read': true }), 'content:analytics:read', false),
|
||||
true,
|
||||
'an explicit member grant should work when the caller role default is denied',
|
||||
);
|
||||
assert.equal(
|
||||
hasResolvedTenantPermission(auth('tenant_owner', { 'content:*': false }), 'content:write', true),
|
||||
false,
|
||||
'explicit deny must apply even to a role that is allowed by default',
|
||||
);
|
||||
|
||||
console.log('[PASS] tenant permission resolution precedence');
|
||||
170
scripts/tenant-resolve-contract-test.js
Normal file
170
scripts/tenant-resolve-contract-test.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { HttpError } from '../apps/api/src/core/errors.ts';
|
||||
import { normalizeTenantHost, selectTenantLocator } from '../apps/api/src/features/tenant/locator.ts';
|
||||
import { resolveTenantRoute } from '../apps/api/src/features/tenant/routes.ts';
|
||||
|
||||
const TENANT_ID = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
function tenantRow(overrides = {}) {
|
||||
return {
|
||||
id: TENANT_ID,
|
||||
slug: 'campus-a',
|
||||
name: 'Campus A',
|
||||
status: 'active',
|
||||
mode: 'saas',
|
||||
host: 'campus-a.example.com',
|
||||
brand_name: 'Campus A',
|
||||
short_name: 'Campus A',
|
||||
slogan: null,
|
||||
logo_url: null,
|
||||
favicon_url: null,
|
||||
service_wechat: null,
|
||||
service_account_name: null,
|
||||
theme: {},
|
||||
public_assets: {},
|
||||
feature_flags: {},
|
||||
admin_feature_flags: {},
|
||||
public_config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requestContext(url, headers = {}) {
|
||||
return {
|
||||
url: new URL(url),
|
||||
req: { headers },
|
||||
res: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function expectHttpError(run, statusCode, code) {
|
||||
await assert.rejects(run, error => {
|
||||
assert.ok(error instanceof HttpError);
|
||||
assert.equal(error.statusCode, statusCode);
|
||||
assert.equal(error.code, code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(normalizeTenantHost('Example.COM.:443'), 'example.com');
|
||||
assert.equal(normalizeTenantHost('[::1]:8787'), '::1');
|
||||
assert.equal(normalizeTenantHost('::1'), '::1');
|
||||
assert.equal(normalizeTenantHost('bad,forwarded.example.com'), '');
|
||||
|
||||
assert.deepEqual(selectTenantLocator({
|
||||
origin: 'https://Campus-A.Example.com:443',
|
||||
requestedHost: 'campus-a.example.com.',
|
||||
requestHost: 'api.example.com',
|
||||
isProduction: true,
|
||||
}), {
|
||||
ok: true,
|
||||
locator: {
|
||||
kind: 'host',
|
||||
host: 'campus-a.example.com',
|
||||
expectedTenantCode: null,
|
||||
source: 'browser',
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(selectTenantLocator({
|
||||
origin: 'null',
|
||||
referer: 'https://campus-a.example.com/pages/student/home',
|
||||
requestHost: 'api.example.com',
|
||||
isProduction: true,
|
||||
}), {
|
||||
ok: true,
|
||||
locator: {
|
||||
kind: 'host',
|
||||
host: 'campus-a.example.com',
|
||||
expectedTenantCode: null,
|
||||
source: 'browser',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(selectTenantLocator({
|
||||
origin: 'https://campus-a.example.com',
|
||||
requestedHost: 'campus-b.example.com',
|
||||
isProduction: true,
|
||||
}).code, 'TENANT_HOST_CONFLICT');
|
||||
|
||||
assert.deepEqual(selectTenantLocator({
|
||||
origin: 'http://localhost:5173',
|
||||
requestedHost: 'localhost:5173',
|
||||
tenantCode: 'campus-a',
|
||||
requestHost: 'localhost:8787',
|
||||
isProduction: false,
|
||||
}), {
|
||||
ok: true,
|
||||
locator: { kind: 'tenantCode', tenantCode: 'campus-a', source: 'local-development' },
|
||||
});
|
||||
|
||||
assert.deepEqual(selectTenantLocator({
|
||||
tenantCode: 'campus-a',
|
||||
requestHost: 'api.example.com',
|
||||
isProduction: true,
|
||||
}), {
|
||||
ok: true,
|
||||
locator: { kind: 'tenantCode', tenantCode: 'campus-a', source: 'headless-client' },
|
||||
});
|
||||
|
||||
assert.equal(selectTenantLocator({
|
||||
requestedHost: 'campus-a.example.com',
|
||||
requestHost: 'api.example.com',
|
||||
isProduction: true,
|
||||
}).code, 'TENANT_HOST_UNTRUSTED');
|
||||
|
||||
{
|
||||
const calls = [];
|
||||
const result = await resolveTenantRoute(
|
||||
requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com', {
|
||||
host: 'api.example.com',
|
||||
origin: 'https://campus-a.example.com',
|
||||
'x-forwarded-host': 'attacker.example.com',
|
||||
}),
|
||||
async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return tenantRow();
|
||||
},
|
||||
);
|
||||
assert.equal(result.tenant.id, TENANT_ID);
|
||||
assert.deepEqual(calls[0].params, ['campus-a.example.com']);
|
||||
assert.match(calls[0].sql, /d\.status = 'active' and t\.status = 'active'/);
|
||||
}
|
||||
|
||||
await expectHttpError(
|
||||
() => resolveTenantRoute(
|
||||
requestContext('https://api.example.com/api/tenant/resolve?host=unknown.example.com', {
|
||||
host: 'api.example.com',
|
||||
origin: 'https://unknown.example.com',
|
||||
}),
|
||||
async () => null,
|
||||
),
|
||||
404,
|
||||
'TENANT_DOMAIN_NOT_BOUND',
|
||||
);
|
||||
|
||||
await expectHttpError(
|
||||
() => resolveTenantRoute(
|
||||
requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com&tenantCode=campus-b', {
|
||||
host: 'api.example.com',
|
||||
origin: 'https://campus-a.example.com',
|
||||
}),
|
||||
async () => tenantRow(),
|
||||
),
|
||||
409,
|
||||
'TENANT_LOCATOR_CONFLICT',
|
||||
);
|
||||
|
||||
await expectHttpError(
|
||||
() => resolveTenantRoute(
|
||||
requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com', {
|
||||
host: 'api.example.com',
|
||||
origin: 'https://campus-b.example.com',
|
||||
}),
|
||||
async () => tenantRow(),
|
||||
),
|
||||
409,
|
||||
'TENANT_HOST_CONFLICT',
|
||||
);
|
||||
|
||||
console.log('[PASS] Tenant resolve fail-closed contract');
|
||||
149
scripts/tenant-student-capacity-contract-test.js
Normal file
149
scripts/tenant-student-capacity-contract-test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
CAPACITY_NAMESPACE,
|
||||
DEFAULT_STUDENT_COUNT,
|
||||
MAX_STUDENT_COUNT,
|
||||
buildStudentListQuery,
|
||||
containsSearchPattern,
|
||||
parseCapacityOptions,
|
||||
safeTarget,
|
||||
} from './tenant-student-capacity.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const harnessPath = path.join(repoRoot, 'scripts', 'tenant-student-capacity.js');
|
||||
const apiPath = path.join(repoRoot, 'apps', 'api', 'src', 'features', 'tenant-admin', 'classes.ts');
|
||||
const migrationPath = path.join(
|
||||
repoRoot,
|
||||
'supabase',
|
||||
'migrations',
|
||||
'202607120011_tenant_student_search_pagination.sql',
|
||||
);
|
||||
const runbookPath = path.join(repoRoot, 'docs', 'refactor', 'tenant-student-capacity-runbook.md');
|
||||
|
||||
const harness = fs.readFileSync(harnessPath, 'utf8');
|
||||
const api = fs.readFileSync(apiPath, 'utf8');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf8');
|
||||
const runbook = fs.readFileSync(runbookPath, 'utf8');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
||||
|
||||
const defaults = parseCapacityOptions([], {});
|
||||
assert.equal(defaults.mode, 'plan');
|
||||
assert.equal(defaults.count, DEFAULT_STUDENT_COUNT);
|
||||
assert.equal(DEFAULT_STUDENT_COUNT, MAX_STUDENT_COUNT);
|
||||
assert.equal(defaults.databaseUrl, '');
|
||||
assert.throws(() => parseCapacityOptions(['--mode=seed'], {}), /DATABASE_URL is required/);
|
||||
assert.throws(
|
||||
() => parseCapacityOptions(['--count=100001'], {}),
|
||||
/count must be between 10 and 100000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseCapacityOptions(['--tenant-slug=master'], {}),
|
||||
/tenant slug must match capacity-test-/,
|
||||
);
|
||||
assert.equal(containsSearchPattern('a_b%c\\d'), '%a\\_b\\%c\\\\d%');
|
||||
assert.throws(
|
||||
() => safeTarget('postgresql://postgres:secret@127.0.0.1:5432/postgres'),
|
||||
/local port 5432 is reserved for tikupro-pg/,
|
||||
);
|
||||
assert.throws(
|
||||
() => safeTarget('postgresql://postgres:secret@tikupro-pg:5432/postgres'),
|
||||
/tikupro-pg targets are forbidden/,
|
||||
);
|
||||
assert.deepEqual(safeTarget('postgresql://postgres:secret@127.0.0.1:55432/postgres'), {
|
||||
host: '127.0.0.1',
|
||||
port: '55432',
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
assert.match(harness, /assertDestructiveTestDatabase\s*\(/);
|
||||
assert.match(harness, /pg_try_advisory_lock/);
|
||||
assert.match(harness, /generate_series\(\$4::integer, \$5::integer\)/);
|
||||
assert.match(harness, /on conflict \(legacy_id\)/i);
|
||||
assert.match(harness, /on conflict \(tenant_id, user_id, role\)/i);
|
||||
assert.match(harness, /on conflict \(tenant_id, user_id\)/i);
|
||||
assert.match(harness, /raw_profile #>> '\{capacityHarness,namespace\}'/);
|
||||
assert.match(harness, /metadata #>> '\{capacityHarness,namespace\}'/);
|
||||
assert.match(harness, /auth_user_id is not null/);
|
||||
assert.match(harness, /tm\.tenant_id <> \$3/);
|
||||
assert.match(harness, /dedicated tenant contains unmanaged memberships/);
|
||||
assert.match(harness, /EXPLAIN \(ANALYZE, BUFFERS, FORMAT JSON\)/i);
|
||||
assert.match(harness, /MAX_STUDENT_COUNT = 100_000/);
|
||||
assert.match(harness, /timingsMs/);
|
||||
const databaseModeStart = harness.indexOf('async function runDatabaseMode(options)');
|
||||
const databaseModeEnd = harness.indexOf('\nexport async function main', databaseModeStart);
|
||||
const databaseMode = harness.slice(databaseModeStart, databaseModeEnd);
|
||||
assert.ok(databaseModeStart >= 0 && databaseModeEnd > databaseModeStart);
|
||||
assert.ok(
|
||||
databaseMode.indexOf('assertDestructiveTestDatabase({') < databaseMode.indexOf('seedFixture(client, options)'),
|
||||
'the destructive database guard must execute before fixture writes',
|
||||
);
|
||||
|
||||
for (const shape of [{}, { cursor: true }, { keyword: true }]) {
|
||||
const benchmarkSql = buildStudentListQuery(shape).sql;
|
||||
for (const fragment of [
|
||||
'with student_page as materialized',
|
||||
'join public.platform_users u on u.id = tm.user_id',
|
||||
'left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id',
|
||||
'join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id',
|
||||
'order by tm.created_at desc, tm.id desc',
|
||||
]) {
|
||||
assert.ok(benchmarkSql.includes(fragment), `benchmark SQL must contain ${fragment}`);
|
||||
assert.ok(api.includes(fragment), `API SQL must contain ${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
const identityExpressionFragments = [
|
||||
"coalesce(u.username, '') || ' '",
|
||||
"coalesce(u.name, '') || ' '",
|
||||
"coalesce(u.phone, '') || ' '",
|
||||
"coalesce(u.email::text, '')",
|
||||
];
|
||||
for (const fragment of identityExpressionFragments) {
|
||||
assert.ok(buildStudentListQuery({ keyword: true }).sql.includes(fragment));
|
||||
assert.ok(api.includes(fragment));
|
||||
assert.ok(migration.includes(fragment.replaceAll('u.', '')));
|
||||
}
|
||||
|
||||
assert.match(migration, /create extension if not exists pg_trgm with schema extensions/i);
|
||||
assert.match(
|
||||
migration,
|
||||
/idx_memberships_student_keyset_page[\s\S]*\(tenant_id, status, created_at desc, id desc\)[\s\S]*where role = 'student'/i,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/idx_platform_users_identity_search_trgm[\s\S]*using gin[\s\S]*gin_trgm_ops/i,
|
||||
);
|
||||
|
||||
assert.equal(CAPACITY_NAMESPACE, 'tiku.student-capacity.v1');
|
||||
for (const scriptName of [
|
||||
'perf:tenant-students:plan',
|
||||
'perf:tenant-students:run',
|
||||
'perf:tenant-students:evidence',
|
||||
'perf:tenant-students:benchmark',
|
||||
'perf:tenant-students:cleanup',
|
||||
'test:tenant-students:capacity:contract',
|
||||
'test:tenant-students:capacity:smoke',
|
||||
]) {
|
||||
assert.ok(packageJson.scripts?.[scriptName], `missing package script ${scriptName}`);
|
||||
}
|
||||
assert.match(packageJson.scripts['test:tenant-students:capacity:smoke'], /--count=250/);
|
||||
assert.match(
|
||||
packageJson.scripts['test:tenant-students:capacity:smoke'],
|
||||
/--tenant-slug=capacity-test-students-smoke/,
|
||||
);
|
||||
assert.match(packageJson.scripts['test:tenant-students:capacity:smoke'], /SMOKE_SEED_LOCAL_OR_CI_ONLY/);
|
||||
assert.ok(packageJson.scripts['test:readiness'].includes('tenant-student-capacity-contract-test.js'));
|
||||
|
||||
assert.match(runbook, /capacity-test-/);
|
||||
assert.match(runbook, /tikupro-pg/i);
|
||||
assert.match(runbook, /SMOKE_SEED_LOCAL_OR_CI_ONLY/);
|
||||
assert.match(runbook, /100000/);
|
||||
assert.match(runbook, /perf:tenant-students:evidence/);
|
||||
assert.match(runbook, /cleanupVerified=true/);
|
||||
assert.match(runbook, /cleanup/i);
|
||||
assert.match(runbook, /not a production SLA/i);
|
||||
|
||||
console.log('[PASS] tenant student capacity harness contract');
|
||||
1114
scripts/tenant-student-capacity.js
Normal file
1114
scripts/tenant-student-capacity.js
Normal file
File diff suppressed because it is too large
Load Diff
31
scripts/tenant-student-cursor-test.js
Normal file
31
scripts/tenant-student-cursor-test.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const {
|
||||
containsSearchPattern,
|
||||
decodeTenantStudentsCursor,
|
||||
encodeTenantStudentsCursor,
|
||||
} = await import('../apps/api/src/features/tenant-admin/student-cursor.ts');
|
||||
|
||||
const cursor = {
|
||||
createdAt: '2026-07-12T03:04:05.678Z',
|
||||
membershipId: '11111111-1111-4111-8111-111111111111',
|
||||
};
|
||||
const encoded = encodeTenantStudentsCursor(cursor);
|
||||
assert.deepEqual(decodeTenantStudentsCursor(encoded), cursor, 'student cursor should round-trip');
|
||||
assert.equal(decodeTenantStudentsCursor(''), null, 'empty cursor should start from the first page');
|
||||
assert.equal(containsSearchPattern('100%_ready\\now'), '%100\\%\\_ready\\\\now%', 'student search should escape LIKE metacharacters');
|
||||
|
||||
for (const invalid of [
|
||||
'not-a-valid-cursor',
|
||||
Buffer.from(JSON.stringify({ version: 2, ...cursor })).toString('base64url'),
|
||||
Buffer.from(JSON.stringify({ version: 1, createdAt: 'not-a-date', membershipId: cursor.membershipId })).toString('base64url'),
|
||||
Buffer.from(JSON.stringify({ version: 1, createdAt: cursor.createdAt, membershipId: 'not-a-uuid' })).toString('base64url'),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => decodeTenantStudentsCursor(invalid),
|
||||
error => error?.code === 'INVALID_STUDENT_CURSOR' && error?.statusCode === 400,
|
||||
'malformed student cursors must fail closed',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[PASS] tenant student cursor contract');
|
||||
138
scripts/worker-scheduling-contract-test.js
Normal file
138
scripts/worker-scheduling-contract-test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseWorkerCli, resolveWorkerMonth } from '../apps/worker/src/cli.ts';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => parseWorkerCli(['--loop']),
|
||||
/--job is required exactly once/,
|
||||
'a production loop without an explicit job must fail closed',
|
||||
);
|
||||
assert.deepEqual(parseWorkerCli(['--loop', '--job', 'crm']), { job: 'crm', loop: true, month: undefined });
|
||||
assert.throws(
|
||||
() => parseWorkerCli(['--loop', '--job', 'platform-billing']),
|
||||
/periodic and must be scheduled with --once/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseWorkerCli(['--once', '--job', 'platform-usage', '--month', 'previous']),
|
||||
{ job: 'platform-usage', loop: false, month: resolveWorkerMonth('previous') },
|
||||
);
|
||||
assert.throws(() => parseWorkerCli(['--once', '--job', 'assets', '--month', 'previous']), /only supported/);
|
||||
assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', '--unknown']), /Unknown worker option/);
|
||||
assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', 'stray']), /Unexpected worker argument/);
|
||||
|
||||
const workerService = read('scripts/deploy/systemd/tiku-worker@.service');
|
||||
const periodicService = read('scripts/deploy/systemd/tiku-worker-job@.service');
|
||||
const monthlyService = read('scripts/deploy/systemd/tiku-worker-monthly-usage.service');
|
||||
const target = read('scripts/deploy/systemd/tiku-workers.target');
|
||||
const workerEnv = read('scripts/deploy/env/worker.env.example');
|
||||
const workerConfig = read('apps/worker/src/config.ts');
|
||||
const sharedDbConfig = read('packages/db/src/index.ts');
|
||||
|
||||
assert.match(workerService, /ExecStart=.*--loop --job %i/);
|
||||
assert.match(periodicService, /Type=oneshot/);
|
||||
assert.match(periodicService, /ExecStart=.*--once --job %i/);
|
||||
assert.match(monthlyService, /--job platform-usage --month previous/);
|
||||
assert.match(monthlyService, /--job platform-usage-overage --month previous/);
|
||||
|
||||
const continuousJobs = [
|
||||
'crm',
|
||||
'commerce',
|
||||
'provider-bills',
|
||||
'platform-dunning-notifications',
|
||||
'platform-audit-notifications',
|
||||
'assets',
|
||||
'imports',
|
||||
'public-banks',
|
||||
'exports',
|
||||
];
|
||||
for (const job of continuousJobs) {
|
||||
assert.match(target, new RegExp(`Requires=tiku-worker@${job}\\.service`), `missing continuous worker ${job}`);
|
||||
}
|
||||
|
||||
const timerNames = [
|
||||
'platform-billing',
|
||||
'platform-usage',
|
||||
'platform-dunning',
|
||||
'platform-audit-alerts',
|
||||
'student-supervision',
|
||||
'monthly-usage',
|
||||
];
|
||||
for (const name of timerNames) {
|
||||
const timerPath = `scripts/deploy/systemd/tiku-worker-${name}.timer`;
|
||||
assert.ok(fs.existsSync(path.join(root, timerPath)), `missing timer ${timerPath}`);
|
||||
assert.match(target, new RegExp(`Wants=tiku-worker-${name}\\.timer`), `target must want ${name} timer`);
|
||||
const timer = read(timerPath);
|
||||
if (timer.includes('OnCalendar=')) {
|
||||
assert.match(timer, /\nPersistent=true\n/, `${timerPath} must catch up after downtime`);
|
||||
} else {
|
||||
assert.match(timer, /\nOnBootSec=/, `${timerPath} must resume its monotonic cadence after boot`);
|
||||
}
|
||||
assert.match(timer, /\nUnit=tiku-worker-(?:job@[^\n]+|monthly-usage\.service)\n/, `${timerPath} must name an explicit service`);
|
||||
}
|
||||
|
||||
const envKeys = new Set(
|
||||
[
|
||||
...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'/g),
|
||||
...sharedDbConfig.matchAll(/positiveEnvNumber\(\s*'([A-Z0-9_]+)'/g),
|
||||
...sharedDbConfig.matchAll(/process\.env\.([A-Z0-9_]+)/g),
|
||||
].map(match => match[1]),
|
||||
);
|
||||
const workerEnvKeys = [...workerEnv.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map(match => match[1]);
|
||||
for (const key of workerEnvKeys) {
|
||||
if (['NODE_ENV', 'DATABASE_URL', 'DB_EXPECTED_RUNTIME_ROLE'].includes(key)) continue;
|
||||
assert.ok(envKeys.has(key), `worker.env.example contains a key not read by worker config: ${key}`);
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'STORAGE_DEFAULT_BUCKET',
|
||||
'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT',
|
||||
'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN',
|
||||
'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS',
|
||||
'WORKER_CRM_POLL_INTERVAL_MS',
|
||||
'WORKER_COMMERCE_POLL_INTERVAL_MS',
|
||||
'WORKER_PROVIDER_BILL_POLL_INTERVAL_MS',
|
||||
'WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS',
|
||||
'WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS',
|
||||
'WORKER_ASSET_POLL_INTERVAL_MS',
|
||||
'WORKER_IMPORT_POLL_INTERVAL_MS',
|
||||
'WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS',
|
||||
'WORKER_EXPORT_POLL_INTERVAL_MS',
|
||||
]) {
|
||||
assert.match(workerEnv, new RegExp(`^${key}=`, 'm'), `worker env must document ${key}`);
|
||||
}
|
||||
|
||||
const schedulerEnvKeys = new Set([
|
||||
'WORKER_PLATFORM_USAGE_MONTH',
|
||||
'WORKER_PLATFORM_USAGE_OVERAGE_MONTH',
|
||||
'ALIYUN_OSS_STS_TOKEN',
|
||||
'EXPORT_PDF_FONT_PATH',
|
||||
]);
|
||||
const codeDefaults = new Map(
|
||||
[...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'\s*,\s*([^\n,)]+)/g)]
|
||||
.map(match => [match[1], match[2].trim()]),
|
||||
);
|
||||
for (const line of workerEnv.split('\n')) {
|
||||
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
||||
if (!match || schedulerEnvKeys.has(match[1])) continue;
|
||||
if (codeDefaults.get(match[1]) === "''") {
|
||||
assert.notEqual(match[2], '', `worker env must not leave required ${match[1]} empty`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const legacyKey of [
|
||||
'ALIYUN_OSS_BUCKET',
|
||||
'ASSET_SECURITY_SCAN_ENDPOINT',
|
||||
'ASSET_SECURITY_SCAN_TOKEN',
|
||||
'WORKER_POLL_INTERVAL_MS',
|
||||
]) {
|
||||
assert.doesNotMatch(workerEnv, new RegExp(`^${legacyKey}=`, 'm'), `worker env must not expose unused ${legacyKey}`);
|
||||
}
|
||||
|
||||
console.log('[PASS] worker CLI and production scheduling contract');
|
||||
Reference in New Issue
Block a user