feat: enforce public bank SaaS scopes

This commit is contained in:
Codex
2026-06-30 08:55:22 +08:00
parent 4f64b7aed8
commit d1cb341351
10 changed files with 576 additions and 80 deletions

View File

@@ -63,7 +63,13 @@ const ids = {
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
questionBank: '00000000-0000-0000-0000-000000000400',
secondPublicRegion: '00000000-0000-0000-0000-0000000003f1',
secondPublicQuestionBank: '00000000-0000-0000-0000-0000000004f1',
secondPublicQuestion: '00000000-0000-0000-0000-0000000004f2',
secondPublicQuestionVersion: '00000000-0000-0000-0000-0000000004f3',
secondPublicQuestionBankGrant: '00000000-0000-0000-0000-0000000009f1',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
partnerSubscription: '00000000-0000-0000-0000-000000000902',
platformOverdueInvoice: crypto.randomUUID(),
};
@@ -6504,6 +6510,104 @@ async function testPublicQuestionBankAdoption() {
});
assert.equal(studentDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not browse adoptable public banks');
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await pool.query(
`
insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active)
values ($1, $2, 'integration-second-region', '烟测第二地区', 'SMOKE-2', 2, true)
on conflict (id)
do update set tenant_id = excluded.tenant_id,
name = excluded.name,
code = excluded.code,
sort_order = excluded.sort_order,
is_active = excluded.is_active
`,
[ids.secondPublicRegion, MAIN_TENANT_ID],
);
await pool.query(
`
insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata)
values ($1, $2, $3, '烟测第二地区公共题库', 'platform', 'active', '{"source":"integration-test","commercialScope":"second_region_bank"}'::jsonb)
on conflict (id)
do update set tenant_id = excluded.tenant_id,
region_id = excluded.region_id,
name = excluded.name,
source_scope = excluded.source_scope,
status = excluded.status,
metadata = excluded.metadata,
updated_at = now()
`,
[ids.secondPublicQuestionBank, MAIN_TENANT_ID, ids.secondPublicRegion],
);
await pool.query(
`
insert into public.questions (
id, tenant_id, question_bank_id, subject_id, category_id,
legacy_id, type, type_label, difficulty, tags, status
)
values ($1, $2, $3, $4, $5, 'integration-second-region-question', 'choice', '单选题', 1, '[]'::jsonb, 'published')
on conflict (id)
do update set question_bank_id = excluded.question_bank_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
status = excluded.status,
updated_at = now()
`,
[ids.secondPublicQuestion, MAIN_TENANT_ID, ids.secondPublicQuestionBank, ids.subject, ids.category],
);
await pool.query(
`
insert into public.question_versions (
id, tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text, explanation, source_hash, created_by
)
values (
$1, $2, $3, 1, '第二地区公共题库题目2 + 2 = ?',
'[{"label":"A","text":"3"},{"label":"B","text":"4"}]'::jsonb,
1, '[1]'::jsonb, '4', '第二地区题库只应授权给购买该地区或全国版的租户。',
'integration-second-region-v1', $4
)
on conflict (id)
do update set content = excluded.content,
options = excluded.options,
correct_option_index = excluded.correct_option_index,
correct_option_indices = excluded.correct_option_indices,
answer_text = excluded.answer_text,
explanation = excluded.explanation,
source_hash = excluded.source_hash
`,
[ids.secondPublicQuestionVersion, MAIN_TENANT_ID, ids.secondPublicQuestion, TENANT_ADMIN_USER_ID],
);
await pool.query(
'update public.questions set current_version_id = $2, updated_at = now() where id = $1',
[ids.secondPublicQuestion, ids.secondPublicQuestionVersion],
);
await pool.query(
`
insert into public.question_bank_grants (
id, source_question_bank_id, grant_scope, allowed_plan_codes,
allowed_region_ids, status, metadata
)
values (
$1, $2, 'plans', array['starter_yearly','pro_yearly']::text[],
array[$3::uuid]::uuid[], 'active', '{"source":"integration-test","scope":"second_region"}'::jsonb
)
on conflict (id)
do update set source_question_bank_id = excluded.source_question_bank_id,
grant_scope = excluded.grant_scope,
allowed_plan_codes = excluded.allowed_plan_codes,
allowed_region_ids = excluded.allowed_region_ids,
status = excluded.status,
metadata = excluded.metadata,
updated_at = now()
`,
[ids.secondPublicQuestionBankGrant, ids.secondPublicQuestionBank, ids.secondPublicRegion],
);
} finally {
await pool.end();
}
const platformBanks = await request('/api/platform-admin/question-banks', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
@@ -6539,7 +6643,85 @@ async function testPublicQuestionBankAdoption() {
});
const adoptable = partnerBanks.items?.find(item => item.grantId === ids.publicQuestionBankGrant);
assert.ok(adoptable, 'partner tenant should see public bank granted by SaaS plan');
assert.equal(adoptable.accessPlanCode, 'starter_yearly', 'adoptable public bank should expose the subscription plan that grants access');
assert.equal(adoptable.accessMode, 'limited_regions', 'adoptable public bank should expose limited region access mode');
assert.equal(adoptable.adoptedId, null, 'public bank should start as not adopted after smoke seed');
assert.equal(
partnerBanks.items?.some(item => item.grantId === ids.secondPublicQuestionBankGrant),
false,
'starter tenant restricted to one region must not see second-region public banks',
);
const secondRegionAdoptDenied = await request('/api/tenant-content/public-question-banks/adopt', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { grantId: ids.secondPublicQuestionBankGrant },
expectStatus: 403,
});
assert.equal(secondRegionAdoptDenied.code, 'QUESTION_BANK_GRANT_NOT_AVAILABLE', 'restricted-region SaaS plan must not adopt out-of-scope public banks');
const upgradePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
const upgradeResult = await upgradePool.query(
`
update public.tenant_subscriptions
set plan_code = 'pro_yearly',
metadata = jsonb_build_object(
'source', 'integration-test-upgrade',
'publicQuestionBankAccess', jsonb_build_object('mode', 'national', 'allowAllRegions', true)
),
updated_at = now()
where id = $1
`,
[ids.partnerSubscription],
);
assert.equal(upgradeResult.rowCount, 1, 'integration test should upgrade exactly one partner subscription');
const upgradedSubscription = await upgradePool.query(
'select plan_code, metadata from public.tenant_subscriptions where id = $1',
[ids.partnerSubscription],
);
assert.equal(upgradedSubscription.rows[0]?.plan_code, 'pro_yearly', 'partner subscription upgrade should persist before public bank list');
assert.equal(
upgradedSubscription.rows[0]?.metadata?.publicQuestionBankAccess?.mode,
'national',
'partner subscription upgrade should persist national public bank access mode',
);
} finally {
await upgradePool.end();
}
const upgradedPartnerBanks = await request('/api/tenant-content/public-question-banks', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
});
const secondRegionVisible = upgradedPartnerBanks.items?.find(item => item.grantId === ids.secondPublicQuestionBankGrant);
assert.ok(secondRegionVisible, 'pro/national SaaS subscription should see second-region public banks');
assert.equal(secondRegionVisible.accessPlanCode, 'pro_yearly', 'upgraded public bank access should be attributed to pro_yearly');
assert.equal(secondRegionVisible.accessMode, 'national', 'upgraded public bank access should expose national mode');
const restoreSubscriptionPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
const restoreResult = await restoreSubscriptionPool.query(
`
update public.tenant_subscriptions
set plan_code = 'starter_yearly',
metadata = jsonb_build_object(
'source', 'integration-test-restore',
'publicQuestionBankAccess', jsonb_build_object(
'mode', 'limited_regions',
'allowedRegionIds', jsonb_build_array($2::text)
)
),
updated_at = now()
where id = $1
`,
[ids.partnerSubscription, ids.region],
);
assert.equal(restoreResult.rowCount, 1, 'integration test should restore partner subscription after national access check');
} finally {
await restoreSubscriptionPool.end();
}
const adopted = await request('/api/tenant-content/public-question-banks/adopt', {
tenantId: PARTNER_TENANT_ID,