feat: add authenticated phone binding

This commit is contained in:
Codex
2026-06-29 09:53:44 +08:00
parent 6f0083bd13
commit 64d58d895e
12 changed files with 441 additions and 105 deletions

View File

@@ -581,6 +581,16 @@ async function loginBySms(phone = '13800000000') {
return verified;
}
async function sendMockSmsCode(phone, purpose) {
const sent = await request('/api/auth/sms/send', {
userId: false,
method: 'POST',
body: { phone, purpose },
});
assert.ok(sent.debugCode, `mock SMS ${purpose} should expose debugCode in local tests`);
return sent.debugCode;
}
async function testTrustedSessionIdentity() {
const login = await loginBySms();
assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user');
@@ -650,6 +660,54 @@ async function testTrustedSessionIdentity() {
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
}
async function testPhoneBinding() {
const phoneSuffix = String(Date.now()).slice(-6);
const oldPhone = `13920${phoneSuffix}`;
const newPhone = `13921${phoneSuffix}`;
const login = await loginBySms(oldPhone);
const authHeaders = { authorization: `Bearer ${login.session.token}` };
const bindCode = await sendMockSmsCode(newPhone, 'bind_phone');
const bound = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: newPhone, code: bindCode },
});
assert.equal(bound.user?.id, login.user.id, 'phone bind should update the authenticated user');
assert.equal(bound.user?.phone, newPhone, 'phone bind should replace platform user phone');
assert.equal(bound.phoneChanged, true, 'phone bind should report phoneChanged for a real change');
const me = await request('/api/auth/me', {
userId: false,
headers: authHeaders,
});
assert.equal(me.user?.phone, newPhone, 'current session should see the newly bound phone');
const oldPhoneLogin = await loginBySms(oldPhone);
assert.notEqual(oldPhoneLogin.user?.id, login.user.id, 'old phone identity should no longer log into the changed account');
const conflictCode = await sendMockSmsCode('13800000000', 'bind_phone');
const conflict = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000000', code: conflictCode },
expectStatus: 409,
});
assert.equal(conflict.code, 'PHONE_ALREADY_BOUND', 'binding a phone owned by another account should be rejected');
const wrongPurposeCode = await sendMockSmsCode('13800000022', 'login');
const wrongPurpose = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000022', code: wrongPurposeCode, purpose: 'login' },
expectStatus: 400,
});
assert.equal(wrongPurpose.code, 'PHONE_BIND_PURPOSE_REQUIRED', 'phone bind endpoint must reject login SMS codes');
}
async function testSupabaseJwtIdentity() {
const studentJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
const studentHeaders = { authorization: `Bearer ${studentJwt}` };
@@ -904,7 +962,10 @@ async function testCatalogAndLearning() {
},
});
assert.equal(nodeSession.item?.contentNodeId, ids.contentNodeProfessional, 'node session should bind parent content node');
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
assert.ok(
[ids.question, ids.questionTwo, ids.questionThree].some(questionId => nodeSession.item?.questionIds?.includes(questionId)),
'node session should include descendant questions',
);
const mockSession = await request('/api/learning/practice-sessions', {
userId: TENANT_ADMIN_USER_ID,
@@ -5426,6 +5487,7 @@ async function main() {
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
await check('trusted session identity', testTrustedSessionIdentity);
await check('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('catalog and learning', testCatalogAndLearning);