forked from wangziqi/gongxue-base
feat: enforce practice access controls
This commit is contained in:
@@ -28,6 +28,8 @@ const ids = {
|
||||
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
|
||||
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
|
||||
question: '00000000-0000-0000-0000-000000000401',
|
||||
questionTwo: '00000000-0000-0000-0000-000000000403',
|
||||
questionThree: '00000000-0000-0000-0000-000000000405',
|
||||
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
|
||||
vocabularyWord: '00000000-0000-0000-0000-000000000812',
|
||||
video: '00000000-0000-0000-0000-000000000821',
|
||||
@@ -565,28 +567,80 @@ async function testCatalogAndLearning() {
|
||||
});
|
||||
assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId');
|
||||
|
||||
const session = await request('/api/learning/practice-sessions', {
|
||||
method: 'POST',
|
||||
body: { userId: USER_ID, mode: 'chapter', targetType: 'category', targetId: ids.question },
|
||||
});
|
||||
assert.ok(session.item?.id, 'practice session should be created');
|
||||
|
||||
const sequentialSession = await request('/api/learning/practice-sessions', {
|
||||
const freeLogin = await loginBySms('13800000008');
|
||||
const freeSession = await request('/api/learning/practice-sessions', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: USER_ID,
|
||||
mode: 'sequential',
|
||||
collectionId: ids.questionCollection,
|
||||
questionLimit: 5,
|
||||
},
|
||||
});
|
||||
assert.equal(sequentialSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection');
|
||||
assert.ok(sequentialSession.item?.questionIds?.includes(ids.question), 'collection session should snapshot question ids');
|
||||
assert.equal(freeSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection');
|
||||
assert.equal(freeSession.item?.accessMode, 'free', 'non-SVIP user should use free quota');
|
||||
assert.equal(freeSession.item?.consumedFreeQuota, 2, 'free practice should consume configured daily quota');
|
||||
assert.equal(freeSession.item?.questionCount, 2, 'free practice should be truncated to configured free limit');
|
||||
assert.equal(freeSession.item?.accessSnapshot?.truncated, true, 'free practice access snapshot should mark truncation');
|
||||
assert.ok(freeSession.item?.questionIds?.includes(ids.question), 'free session should snapshot first question');
|
||||
|
||||
const nodeSession = await request('/api/learning/practice-sessions', {
|
||||
const freeLimitReached = await request('/api/learning/practice-sessions', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: USER_ID,
|
||||
mode: 'sequential',
|
||||
collectionId: ids.questionCollection,
|
||||
questionLimit: 5,
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(freeLimitReached.code, 'PRACTICE_FREE_LIMIT_REACHED', 'second free session should be blocked after quota is exhausted');
|
||||
|
||||
const forbiddenAnswer = await request('/api/learning/answers', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||
method: 'POST',
|
||||
body: {
|
||||
questionId: ids.questionThree,
|
||||
selectedOptions: ['1'],
|
||||
practiceSessionId: freeSession.item.id,
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(forbiddenAnswer.code, 'PRACTICE_SESSION_QUESTION_FORBIDDEN', 'answers outside the session snapshot should be rejected');
|
||||
|
||||
const answer = await request('/api/learning/answers', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||
method: 'POST',
|
||||
body: {
|
||||
questionId: ids.question,
|
||||
selectedOptions: ['0'],
|
||||
practiceSessionId: freeSession.item.id,
|
||||
},
|
||||
});
|
||||
assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false');
|
||||
|
||||
const staffSequentialSession = await request('/api/learning/practice-sessions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
mode: 'sequential',
|
||||
collectionId: ids.questionCollection,
|
||||
questionLimit: 5,
|
||||
},
|
||||
});
|
||||
assert.equal(staffSequentialSession.item?.accessMode, 'staff', 'tenant staff should bypass free practice quota');
|
||||
assert.ok(staffSequentialSession.item?.questionIds?.includes(ids.questionThree), 'staff session should receive the full collection');
|
||||
|
||||
const nodeSession = await request('/api/learning/practice-sessions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
mode: 'random',
|
||||
contentNodeId: ids.contentNodeProfessional,
|
||||
questionLimit: 5,
|
||||
@@ -596,9 +650,10 @@ async function testCatalogAndLearning() {
|
||||
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
|
||||
|
||||
const mockSession = await request('/api/learning/practice-sessions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: USER_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
blueprintId: ids.practiceBlueprintMock,
|
||||
},
|
||||
});
|
||||
@@ -608,18 +663,11 @@ async function testCatalogAndLearning() {
|
||||
assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score');
|
||||
assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions');
|
||||
|
||||
const answer = await request('/api/learning/answers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: USER_ID,
|
||||
questionId: ids.question,
|
||||
selectedOptions: ['0'],
|
||||
practiceSessionId: session.item.id,
|
||||
},
|
||||
const wrong = await request('/api/learning/wrong-questions', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||
query: { status: 'all' },
|
||||
});
|
||||
assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false');
|
||||
|
||||
const wrong = await request('/api/learning/wrong-questions', { query: { status: 'all' } });
|
||||
assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ const ids = {
|
||||
questionBank: '00000000-0000-0000-0000-000000000400',
|
||||
question: '00000000-0000-0000-0000-000000000401',
|
||||
questionVersion: '00000000-0000-0000-0000-000000000402',
|
||||
questionTwo: '00000000-0000-0000-0000-000000000403',
|
||||
questionTwoVersion: '00000000-0000-0000-0000-000000000404',
|
||||
questionThree: '00000000-0000-0000-0000-000000000405',
|
||||
questionThreeVersion: '00000000-0000-0000-0000-000000000406',
|
||||
plan: '00000000-0000-0000-0000-000000000201',
|
||||
order: '00000000-0000-0000-0000-000000000701',
|
||||
payment: '00000000-0000-0000-0000-000000000702',
|
||||
@@ -73,6 +77,97 @@ async function main() {
|
||||
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with transient_users as (
|
||||
select u.id
|
||||
from public.platform_users u
|
||||
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||
)
|
||||
delete from public.practice_access_events
|
||||
where tenant_id = $1
|
||||
and user_id in (select id from transient_users)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with transient_users as (
|
||||
select u.id
|
||||
from public.platform_users u
|
||||
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||
)
|
||||
delete from public.practice_daily_usage
|
||||
where tenant_id = $1
|
||||
and user_id in (select id from transient_users)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with transient_users as (
|
||||
select u.id
|
||||
from public.platform_users u
|
||||
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||
)
|
||||
delete from public.answer_records
|
||||
where tenant_id = $1
|
||||
and user_id in (select id from transient_users)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with transient_users as (
|
||||
select u.id
|
||||
from public.platform_users u
|
||||
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||
)
|
||||
delete from public.practice_sessions
|
||||
where tenant_id = $1
|
||||
and user_id in (select id from transient_users)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with transient_users as (
|
||||
select u.id
|
||||
from public.platform_users u
|
||||
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||
)
|
||||
delete from app_private.auth_sessions
|
||||
where tenant_id = $1
|
||||
and user_id in (select id from transient_users)
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with transient_users as (
|
||||
select u.id
|
||||
from public.platform_users u
|
||||
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||
)
|
||||
delete from public.user_identities
|
||||
where user_id in (select id from transient_users)
|
||||
`,
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone in ('13800000006', '13800000007', '13800000008')
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.referral_qrcodes
|
||||
@@ -328,13 +423,14 @@ async function main() {
|
||||
id, tenant_id, region_id, entry_id, node_id, subject_id, category_id,
|
||||
question_bank_id, legacy_id, name, collection_type, source_type,
|
||||
filters, question_count, total_score, duration_minutes, status,
|
||||
sort_order, metadata, created_by, updated_by
|
||||
sort_order, access_rules, metadata, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions',
|
||||
'{"tabs":["all","paper","chapter","type"]}'::jsonb, 0, 100, 120, 'active',
|
||||
1, '{"business":"supports sequential random mock exam"}'::jsonb, $9, $9
|
||||
1, '{"freeDailyLimit":2,"freeSessionLimit":2,"freeQuotaScopeType":"tenant"}'::jsonb,
|
||||
'{"business":"supports sequential random mock exam"}'::jsonb, $9, $9
|
||||
)
|
||||
on conflict (id)
|
||||
do update set node_id = excluded.node_id,
|
||||
@@ -348,6 +444,7 @@ async function main() {
|
||||
total_score = excluded.total_score,
|
||||
duration_minutes = excluded.duration_minutes,
|
||||
status = 'active',
|
||||
access_rules = excluded.access_rules,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
@@ -370,11 +467,22 @@ async function main() {
|
||||
entry_id, content_node_id, primary_collection_id,
|
||||
legacy_id, type, type_label, difficulty, status, exam_markers
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
'smoke-question', 'choice', '单选题', 1, 'published',
|
||||
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||
)
|
||||
values
|
||||
(
|
||||
$1, $2, $5, $6, $7, $8, $9, $10,
|
||||
'smoke-question', 'choice', '单选题', 1, 'published',
|
||||
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||
),
|
||||
(
|
||||
$3, $2, $5, $6, $7, $8, $9, $10,
|
||||
'smoke-question-2', 'choice', '单选题', 1, 'published',
|
||||
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||
),
|
||||
(
|
||||
$4, $2, $5, $6, $7, $8, $9, $10,
|
||||
'smoke-question-3', 'choice', '单选题', 1, 'published',
|
||||
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set question_bank_id = excluded.question_bank_id,
|
||||
subject_id = excluded.subject_id,
|
||||
@@ -388,6 +496,8 @@ async function main() {
|
||||
[
|
||||
ids.question,
|
||||
tenantId,
|
||||
ids.questionTwo,
|
||||
ids.questionThree,
|
||||
ids.questionBank,
|
||||
ids.subject,
|
||||
ids.category,
|
||||
@@ -402,14 +512,17 @@ async function main() {
|
||||
insert into public.question_collection_items (
|
||||
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
|
||||
)
|
||||
values ($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb)
|
||||
values
|
||||
($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb),
|
||||
($1, $2, $4, 'choice', 2, 2, true, '{"source":"smoke-seed"}'::jsonb),
|
||||
($1, $2, $5, 'choice', 3, 2, true, '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (tenant_id, collection_id, question_id)
|
||||
do update set section_key = excluded.section_key,
|
||||
sort_order = excluded.sort_order,
|
||||
score = excluded.score,
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, ids.questionCollection, ids.question],
|
||||
[tenantId, ids.questionCollection, ids.question, ids.questionTwo, ids.questionThree],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
@@ -487,11 +600,22 @@ async function main() {
|
||||
id, tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text, explanation
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 1, '1 + 1 = ?',
|
||||
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '2', '基础加法。'
|
||||
)
|
||||
values
|
||||
(
|
||||
$1, $2, $4, 1, '1 + 1 = ?',
|
||||
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '2', '基础加法。'
|
||||
),
|
||||
(
|
||||
$3, $2, $5, 1, '2 + 2 = ?',
|
||||
'[{"label":"A","text":"3"},{"label":"B","text":"4"},{"label":"C","text":"5"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '4', '基础加法。'
|
||||
),
|
||||
(
|
||||
$6, $2, $7, 1, '3 + 3 = ?',
|
||||
'[{"label":"A","text":"5"},{"label":"B","text":"6"},{"label":"C","text":"7"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '6', '基础加法。'
|
||||
)
|
||||
on conflict (question_id, version_no)
|
||||
do update set content = excluded.content,
|
||||
options = excluded.options,
|
||||
@@ -500,16 +624,31 @@ async function main() {
|
||||
answer_text = excluded.answer_text,
|
||||
explanation = excluded.explanation
|
||||
`,
|
||||
[ids.questionVersion, tenantId, ids.question],
|
||||
[ids.questionVersion, tenantId, ids.questionTwoVersion, ids.question, ids.questionTwo, ids.questionThreeVersion, ids.questionThree],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set current_version_id = $3, has_video_explanation = true, updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
update public.questions q
|
||||
set current_version_id = v.version_id,
|
||||
has_video_explanation = case when q.id = $2::uuid then true else q.has_video_explanation end,
|
||||
updated_at = now()
|
||||
from (values
|
||||
($2::uuid, $3::uuid),
|
||||
($4::uuid, $5::uuid),
|
||||
($6::uuid, $7::uuid)
|
||||
) as v(question_id, version_id)
|
||||
where q.tenant_id = $1 and q.id = v.question_id
|
||||
`,
|
||||
[tenantId, ids.question, ids.questionVersion],
|
||||
[
|
||||
tenantId,
|
||||
ids.question,
|
||||
ids.questionVersion,
|
||||
ids.questionTwo,
|
||||
ids.questionTwoVersion,
|
||||
ids.questionThree,
|
||||
ids.questionThreeVersion,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
|
||||
Reference in New Issue
Block a user