feat: complete registration invitation and auth redesign

This commit is contained in:
richjohnrambo
2026-08-19 00:30:11 +08:00
parent e063e7879e
commit 219ecadf4c
47 changed files with 9014 additions and 224 deletions
+162 -1
View File
@@ -1074,11 +1074,48 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
t.Fatalf("tenant payload must not expose internal id: %#v", tenant)
}
endpoints := data["endpoints"].(map[string]any)
if endpoints["websocket"] != "https://im.example.com/v0/channels" {
if endpoints["websocket"] != "wss://im.example.com/v0/channels" {
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
}
}
func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
oldTenants := store.Tenants
oldServingAt := globals.servingAt
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
defer func() {
store.Tenants = oldTenants
globals.servingAt = oldServingAt
}()
globals.servingAt = "http://:6060/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Host = "internal:6060"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.118im.com")
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
endpoints := data["endpoints"].(map[string]any)
if endpoints["websocket"] != "wss://api.118im.com/v0/channels" ||
endpoints["api"] != "https://api.118im.com/v1/im" ||
endpoints["file"] != "https://api.118im.com/v0/file" {
t.Fatalf("unexpected endpoints: %#v", endpoints)
}
}
func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
@@ -1204,6 +1241,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
@@ -1212,9 +1250,20 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
}}
verifications := &testVerificationStore{}
store.Verifications = verifications
setRegistrationSMSConfigForTest(true)
var deliveredCode string
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
if recipient != "+8613800138000" || scene != "register" {
t.Fatalf("unexpected delivery: recipient=%q scene=%q", recipient, scene)
}
deliveredCode = code
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
resetRegistrationSMSConfigForTest()
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
@@ -1237,6 +1286,11 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) {
t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String())
}
if len(deliveredCode) != imVerificationCodeLength ||
verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID,
verifications.created.PhoneHash, deliveredCode) {
t.Fatalf("stored challenge does not match delivered code %q", deliveredCode)
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 {
@@ -1244,6 +1298,113 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
}
}
func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
setRegistrationSMSConfigForTest(false)
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
t.Fatal("SMS sender must not be called when registration SMS is disabled")
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
resetRegistrationSMSConfigForTest()
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK || verifications.created == nil {
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
}
}
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldUsers := store.Users
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil)
store.Users = users
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength {
t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code)
}
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
store.Users = oldUsers
sendIMVerificationCode = oldSender
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) {
t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created)
}
}
func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
setRegistrationSMSConfigForTest(true)
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal }
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
resetRegistrationSMSConfigForTest()
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusBadGateway || !verifications.failed {
t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String())
}
if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed {
t.Fatalf("reason=%q", resp.Reason)
}
}
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))